Java7-入门手册-全-

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/9781430239093](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/java/codeconv-138413.html](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/java/javase/downloads/index-jsp-138363.html](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/java/javase/downloads/index-jsp-138363.html](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://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) 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://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) 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/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf](http://www.oracle.com/technetwork/java/javase/tech/memorymanagement-whitepaper-1-150020.pdf)的“Java HotSpot 虚拟机中的内存管理”白皮书开始。接下来,您将想要了解垃圾优先收集器,这是 Java 7 中的新特性。查看“垃圾优先的垃圾收集器”白皮书([www.oracle.com/technetwork/java/javase/tech/g1-intro-jsp-135488.html](http://www.oracle.com/technetwork/java/javase/tech/g1-intro-jsp-135488.html))来了解这个垃圾收集器。有关 Java 垃圾收集过程的更多信息,您可以在[www.oracle.com/technetwork/java/javase/tech/index-jsp-140228.html](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 对这些和其他高级语言特性的支持。*

三、探索高级语言功能

第一章和第二章向你介绍了 Java 的基本语言特性以及它对类和对象的支持。第三章在此基础上向您介绍 Java 的高级语言特性,特别是那些与嵌套类型、包、静态导入、异常、断言、注释、泛型和枚举相关的特性。

嵌套类型

在任何类之外声明的类被称为顶级类。Java 还支持嵌套的类,这些类被声明为其他类或作用域的成员。嵌套类有助于实现顶级类架构。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三个类别被称为内部类

本节向您介绍静态成员类和内部类。对于每一种嵌套类,我都为您提供了一个简短的介绍、一个抽象的示例和一个更实用的示例。然后,这一节简要分析了在类中嵌套接口的主题。

静态成员类

一个静态成员类是封闭类的static成员。虽然是封闭的,但它没有该类的封闭实例,并且不能访问封闭类的实例字段和调用其实例方法。然而,它可以访问封闭类的static字段并调用其static方法,甚至是那些被声明为private的成员。清单 3-1 给出了一个静态成员类声明。

清单 3-1。声明一个静态成员类

class EnclosingClass
{
   private static int i;
   private static void m1()
   {
      System.out.println(i);
   }
   static void m2()
   {
      EnclosedClass.accessEnclosingClass();
   }
   static class EnclosedClass
   {
      static void accessEnclosingClass()
      {
         i = 1;
         m1();
      }
      void accessEnclosingClass2()
      {
         m2();
      }
   }
}

清单 3-1 声明了一个名为EnclosingClass的顶级类,带有类字段i,类方法m1()m2(),以及静态成员类EnclosedClass。另外,EnclosedClass声明了类方法accessEnclosingClass()和实例方法accessEnclosingClass2()

因为accessEnclosingClass()被声明为staticm2()必须在这个方法的名字前加上EnclosedClass和成员访问操作符来调用这个方法。

清单 3-2 给出了一个应用的源代码,演示了如何调用EnclosedClassaccessEnclosingClass()类方法,实例化EnclosedClass并调用其accessEnclosingClass2()实例方法。

清单 3-2。调用静态成员类的类和实例方法

class SMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass.EnclosedClass.accessEnclosingClass(); // Output: 1
      EnclosingClass.EnclosedClass ec = new EnclosingClass.EnclosedClass();
      ec.accessEnclosingClass2(); // Output: 1
   }
}

清单 3-2 的main()方法揭示了你必须在一个封闭类的名字前加上其封闭类的名字来调用一个类方法;比如EnclosingClass.EnclosedClass.accessEnclosingClass();

这个清单还揭示了在实例化封闭类时,必须在封闭类的名称前加上其封闭类的名称;比如EnclosingClass.EnclosedClass ec = new EnclosingClass.EnclosedClass();。然后,您可以以正常方式调用实例方法;比如ec.accessEnclosingClass2();

静态成员类有它们的用途。例如,清单 3-3 的DoubleFloat静态成员类提供了它们的封闭Rectangle类的不同实现。Float版本因其 32 位float字段而占用更少的内存,而Double版本因其 64 位double字段而提供更高的精度。

清单 3-3。使用静态成员类声明其封闭类的多个实现

abstract class Rectangle
{
   abstract double getX();
   abstract double getY();
   abstract double getWidth();
   abstract double getHeight();
   static class Double extends Rectangle
   {
      private double x, y, width, height;
      Double(double x, double y, double width, double height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }
      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }
   static class Float extends Rectangle
   {
      private float x, y, width, height;
      Float(float x, float y, float width, float height)
      {
         this.x = x;
         this.y = y;
         this.width = width;
         this.height = height;
      }
      double getX() { return x; }
      double getY() { return y; }
      double getWidth() { return width; }
      double getHeight() { return height; }
   }
   // Prevent subclassing. Use the type-specific Double and Float
   // implementation subclass classes to instantiate.
   private Rectangle() {}
   boolean contains(double x, double y)
   {
      return (x >= getX() && x < getX()+getWidth()) &&
             (y >= getY() && y < getY()+getHeight());
   }
}

清单 3-3 的Rectangle类展示了嵌套的子类。每个DoubleFloat静态成员类继承抽象Rectangle类,提供私有浮点或双精度浮点字段,并覆盖Rectangle的抽象方法以返回这些字段的值作为double

Rectangle是抽象的,因为实例化这个类没有意义。因为用新的实现直接扩展Rectangle也没有意义(嵌套的DoubleFloat子类应该足够了),所以它的默认构造函数被声明为private。相反,你必须实例化Rectangle.Float(为了节省内存)或Rectangle.Double(当需要准确性时),如清单 3-4 所示。

清单 3-4。实例化嵌套子类

class SMCDemo
{
   public static void main(String[] args)
   {
      Rectangle r = new Rectangle.Double(10.0, 10.0, 20.0, 30.0);
      System.out.println("x = "+r.getX());
      System.out.println("y = "+r.getY());
      System.out.println("width = "+r.getWidth());
      System.out.println("height = "+r.getHeight());
      System.out.println("contains(15.0, 15.0) = "+r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = "+r.contains(0.0, 0.0));
      System.out.println();
      r = new Rectangle.Float(10.0f, 10.0f, 20.0f, 30.0f);
      System.out.println("x = "+r.getX());
      System.out.println("y = "+r.getY());
      System.out.println("width = "+r.getWidth());
      System.out.println("height = "+r.getHeight());
      System.out.println("contains(15.0, 15.0) = "+r.contains(15.0, 15.0));
      System.out.println("contains(0.0, 0.0) = "+r.contains(0.0, 0.0));
   }
}

清单 3-4 首先通过new Rectangle.Double(10.0, 10.0, 20.0, 30.0)实例化RectangleDouble子类,然后调用其各种方法。继续,清单 3-4 在调用这个实例上的Rectangle方法之前,通过new Rectangle.Float(10.0f, 10.0f, 20.0f, 30.0f)实例化RectangleFloat子类。

编译这两个清单(javac SMCDemo.javajavac *.java)并运行应用(java SMCDemo)。然后,您将看到以下输出:

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

x = 10.0
y = 10.0
width = 20.0
height = 30.0
contains(15.0, 15.0) = true
contains(0.0, 0.0) = false

Java 的类库包含了很多静态成员类。例如,java.lang.Character类包含一个名为Subset的静态成员类,其实例代表 Unicode 字符集的子集。其他的例子有java.util.AbstractMap.SimpleEntryjava.io.ObjectInputStream.GetFieldjava.security.KeyStore.PrivateKeyEntry

images 注意当你编译一个包含静态成员类的封闭类时,编译器为静态成员类创建一个类文件,其名称由封闭类的名称、美元符号字符和静态成员类的名称组成。例如,编译清单 3-1 ,你会发现EnclosingClass$EnclosedClass.classEnclosingClass.class。这种格式也适用于非静态成员类。

非静态成员类

非静态成员类是封闭类的非static成员。非静态成员类的每个实例都隐式地与封闭类的一个实例相关联。非静态成员类的实例方法可以调用封闭类中的实例方法,并访问封闭类实例的非静态字段。清单 3-5 展示了一个非静态成员类声明。

清单 3-5。声明一个非静态成员类

class EnclosingClass
{
   private int i;
   private void m()
   {
      System.out.println(i);
   }
   class EnclosedClass
   {
      void accessEnclosingClass()
      {
         i = 1;
         m();
      }
   }
}

清单 3-5 声明了一个名为EnclosingClass的顶级类,带有实例字段i、实例方法m()和非静态成员类EnclosedClass。此外,EnclosedClass声明了实例方法accessEnclosingClass()

因为accessEnclosingClass()是非静态的,所以在调用这个方法之前必须实例化EnclosedClass。这个实例化必须通过EnclosingClass的一个实例发生。清单 3-6 完成了这些任务。

清单 3-6。调用非静态成员类的实例方法

class NSMCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.new EnclosedClass().accessEnclosingClass(); // Output: 1
   }
}

清单 3-6 的main()方法首先实例化EnclosingClass并将它的引用保存在局部变量ec中。然后,main()使用这个引用作为new操作符的前缀,实例化EnclosedClass,然后使用其引用调用accessEnclosingClass(),后者输出1

images 注意很少在new前面加上对封闭类的引用。相反,您通常会从构造函数或其封闭类的实例方法中调用封闭类的构造函数。

假设您需要维护一个待办事项列表,其中每个项目都由一个名称和一个描述组成。经过一番思考后,您创建了清单 3-7 中的和的ToDo类来实现这些项目。

清单 3-7。将待办事项实现为名称-描述对

class ToDo
{
   private String name;
   private String desc;
   ToDo(String name, String desc)
   {
      this.name = name;
      this.desc = desc;
   }
   String getName()
   {
      return name;
   }
   String getDesc()
   {
      return desc;
   }
   @Override
   public String toString()
   {
      return "Name = "+getName()+", Desc = "+getDesc();
   }
}

接下来创建一个ToDoList类来存储ToDo实例。ToDoList使用它的ToDoArray非静态成员类在一个可增长的数组中存储ToDo实例——你不知道会存储多少实例,而 Java 数组有固定的长度。参见清单 3-8 。

清单 3-8。在一个ToDoArray实例中最多存储两个ToDo实例

class ToDoList
{
   private ToDoArray toDoArray;
   private int index = 0;
   ToDoList()
   {
      toDoArray = new ToDoArray(2);
   }
   boolean hasMoreElements()
   {
      return index < toDoArray.size();
   }
   ToDo nextElement()
   {
      return toDoArray.get(index++);
   }
   void add(ToDo item)
   {
      toDoArray.add(item);
   }
   private class ToDoArray
   {
      private ToDo[] toDoArray;
      private int index = 0;
      ToDoArray(int initSize)
      {
         toDoArray = new ToDo[initSize];
      }
      void add(ToDo item)
      {
         if (index >= toDoArray.length)
         {
            ToDo[] temp = new ToDo[toDoArray.length*2];
            for (int i = 0; i < toDoArray.length; i++)
               temp[i] = toDoArray[i];
            toDoArray = temp;
         }
         toDoArray[index++] = item;
      }
      ToDo get(int i)
      {
         return toDoArray[i];
      }
      int size()
      {
         return index;
      }
   }
}

除了提供一个add()方法来将ToDo实例存储在ToDoArray实例中,ToDoList还提供了hasMoreElements()nextElement()方法来迭代并返回存储的实例。清单 3-9 展示了这些方法。

清单 3-9。创建并迭代ToDo个实例中的ToDoList

class NSMCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList();
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      while (toDoList.hasMoreElements())
         System.out.println(toDoList.nextElement());
   }
}

编译所有三个清单(javac NSMCDemo.javajavac *.java)并运行应用(java NSMCDemo)。然后,您将看到以下输出:

Name = #1, Desc = Do laundry.
Name = #2, Desc = Buy groceries.
Name = #3, Desc = Vacuum apartment.
Name = #4, Desc = Write report.
Name = #5, Desc = Wash car.

Java 的类库提供了许多非静态成员类的例子。例如,java.util包的HashMap类声明私有的HashIteratorValueIteratorKeyIteratorEntryIterator类,用于迭代 hashmap 的值、键和条目。(我会在第五章讨论HashMap。)

images 注意封闭类中的代码可以通过用封闭类的名称和成员访问操作符限定保留字this来获得对其封闭类实例的引用。例如,如果accessEnclosingClass()中的代码需要获得对其EnclosingClass实例的引用,它会指定EnclosingClass.this

匿名班级

匿名类是没有名字的类。此外,它不是其封闭类的成员。相反,一个匿名类被同时声明(作为一个类的匿名扩展或者作为一个接口的匿名实现)并且在任何合法指定表达式的地方被实例化。清单 3-10 展示了一个匿名的类声明和实例化。

清单 3-10。声明并实例化一个扩展了一个类的匿名类

abstract class Speaker
{
   abstract void speak();
}
class ACDemo
{
   public static void main(final String[] args)
   {
      new Speaker()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";
         @Override
         void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 3-10 引入了一个名为Speaker的抽象类和一个名为ACDemo的具体类。后一个类的main()方法声明了一个扩展Speaker并覆盖其speak()方法的匿名类。当这个方法被调用时,它输出main()的第一个命令行参数,如果没有参数,则输出一个默认消息;比如java ACDemo Hello输出Hello

匿名类没有构造函数(因为匿名类没有名字)。但是,它的 classfile 包含一个执行实例初始化的<init>()方法。这个方法调用超类的 noargument 构造函数(在任何其他初始化之前),这就是在new之后指定Speaker()的原因。

匿名类实例应该能够访问周围范围的局部变量和参数。但是,实例可能比设计它的方法活得长(由于将实例的引用存储在字段中),并且在方法返回后尝试访问不再存在的局部变量和参数。

因为 Java 不允许这种非法访问,这很可能会导致 Java 虚拟机(JVM)崩溃,所以它只允许匿名类实例访问声明为final的局部变量和参数。在匿名类实例中遇到最终的局部变量/参数名时,编译器会做两件事之一:

  • 如果变量的类型是原语(例如,intdouble),编译器会用变量的只读值替换它的名称。
  • 如果变量的类型是引用(例如,java.lang.String),编译器会在类文件中引入一个合成变量(一个人造变量)和在合成变量中存储局部变量/参数引用的代码。

清单 3-11 展示了另一种匿名类声明和实例化。

清单 3-11。声明并实例化一个实现接口的匿名类

interface Speakable
{
   void speak();
}
class ACDemo
{
   public static void main(final String[] args)
   {
      new Speakable()
      {
         String msg = (args.length == 1) ? args[0] : "nothing to say";
         @Override
         public void speak()
         {
            System.out.println(msg);
         }
      }
      .speak();
   }
}

清单 3-11 与清单 3-10 非常相似。然而,这个清单中的匿名类实现了一个名为Speakable的接口,而不是子类化一个Speaker类。除了<init>()方法调用java.lang.Object()(接口没有构造函数)之外,清单 3-11 的行为类似于清单 3-10 。

尽管匿名类没有构造函数,但是您可以提供一个实例初始化器来处理复杂的初始化。例如,new Office() {{addEmployee(new Employee("John Doe"));}};实例化Office的匿名子类,并通过调用OfficeaddEmployee()方法向该实例添加一个Employee对象。

为了方便起见,您经常会发现自己在创建和实例化匿名类。例如,假设您需要返回一个包含所有带有“.java”后缀的文件名的列表。下面的例子向您展示了匿名类如何简化使用java.io包的FileFilenameFilter类来实现这个目标:

String[] list = new File(directory).list(new FilenameFilter()
                {
                   @Override
                   public boolean accept(File f, String s)
                   {
                      return s.endsWith(".java");
                   }
                });

地方班

一个局部类是一个在声明局部变量的任何地方声明的类。此外,它的作用域与局部变量相同。与匿名类不同,局部类有一个名字,可以重用。像匿名类一样,局部类只有在非静态上下文中使用时才有封闭实例。

局部类实例可以访问周围作用域的局部变量和参数。但是,被访问的局部变量和参数必须声明为final。例如,清单 3-12 的局部类声明访问一个最终参数和一个最终局部变量。

清单 3-12。声明一个本地类

class EnclosingClass
{
   void m(final int x)
   {
      final int y = x*2;
      class LocalClass
      {
         int a = x;
         int b = y;
      }
      LocalClass lc = new LocalClass();
      System.out.println(lc.a);
      System.out.println(lc.b);
   }
}

清单 3-12 用它的实例方法m()声明了一个名为LocalClass的局部类EnclosingClass。这个局部类声明了一对实例字段(ab),当LocalClass被实例化时,它们被初始化为final参数xfinal局部变量y的值:例如new EnclosingClass().m(10);

清单 3-13 展示了这个局部类。

清单 3-13。演示一个本地类

class LCDemo
{
   public static void main(String[] args)
   {
      EnclosingClass ec = new EnclosingClass();
      ec.m(10);
   }
}

实例化EnclosingClass,清单 3-13 的main()方法调用m(10)。被调用的m()方法将该参数乘以 2,实例化LocalClass,其<init>()方法将该参数和双精度值分配给它的一对实例字段(代替使用构造函数来执行该任务),并输出LocalClass实例字段。以下输出结果:

10
20

局部类有助于提高代码的清晰度,因为它们可以被移动到离需要它们的地方更近的地方。例如,清单 3-14 声明了一个Iterator接口和一个重构的ToDoList类,其iterator()方法返回其本地Iter类的一个实例作为Iterator实例(因为Iter实现了Iterator)。

清单 3-14。Iterator接口和重构后的ToDoList

interface Iterator
{
   boolean hasMoreElements();
   Object nextElement();
}
class ToDoList
{
   private ToDo[] toDoList;
   private int index = 0;
   ToDoList(int size)
   {
      toDoList = new ToDo[size];
   }
   Iterator iterator()
   {
      class Iter implements Iterator
      {
         int index = 0;
         @Override
         public boolean hasMoreElements()
         {
            return index < toDoList.length;
         }
         @Override
         public Object nextElement()
         {
            return toDoList[index++];
         }
      }
      return new Iter();
   }
   void add(ToDo item)
   {
      toDoList[index++] = item;
   }
}

清单 3-15 展示了Iterator,重构后的ToDoList类,以及清单 3-7 的ToDo类。

清单 3-15。使用可重用的迭代器创建并迭代ToDo个实例中的ToDoList

class LCDemo
{
   public static void main(String[] args)
   {
      ToDoList toDoList = new ToDoList(5);
      toDoList.add(new ToDo("#1", "Do laundry."));
      toDoList.add(new ToDo("#2", "Buy groceries."));
      toDoList.add(new ToDo("#3", "Vacuum apartment."));
      toDoList.add(new ToDo("#4", "Write report."));
      toDoList.add(new ToDo("#5", "Wash car."));
      Iterator iter = toDoList.iterator();
      while (iter.hasMoreElements())
         System.out.println(iter.nextElement());
   }
}

iterator()返回的Iterator实例返回的ToDo条目的顺序与它们被添加到列表中的顺序相同。虽然你只能使用返回的Iterator对象一次,但是当你需要一个新的Iterator对象时,你可以调用iterator()。这个功能比清单 3-9 中的单次迭代器有了很大的改进。

类内的接口

接口可以嵌套在类中。一旦被声明,接口就被认为是静态的,即使它没有被声明static。例如,清单 3-16 声明了一个名为X的封闭类以及两个名为AB的嵌套静态接口。

清单 3-16。在一个类中声明一对接口

class X
{
   interface A
   {
   }
   static interface B
   {
   }
}

你可以用同样的方式访问清单 3-16 的界面。例如,您可以指定class C implements X.A {}class D implements X.B {}

与嵌套类一样,嵌套接口通过嵌套类来实现,从而有助于实现顶级类架构。总的来说,这些类型是嵌套的,因为它们不能(如在清单 3-14 的Iter局部类中)或者不需要出现在与顶级类相同的级别上并污染它的包命名空间。

images 第二章的接口介绍向你展示了如何在接口体中声明常量和方法头。也可以在接口体中声明接口和类。因为很少有好的理由这样做(在第五章的中讨论的java.util.Map.Entry,是一个例外),所以最好避免在接口中嵌套接口和/或类。

套餐

层次结构根据项目之间存在的层次关系来组织项目。例如,一个文件系统可能包含一个带有多个年份子目录的taxes目录,其中每个子目录包含与该年相关的税务信息。此外,封闭类可能包含多个嵌套类,这些嵌套类只在封闭类的上下文中有意义。

分层结构也有助于避免名称冲突。例如,在非分层文件系统(由单个目录组成)中,两个文件不能同名。相比之下,分层文件系统允许同名文件存在于不同的目录中。类似地,两个封闭类可以包含同名的嵌套类。不存在名称冲突,因为项目被划分到不同的名称空间

Java 还支持将顶级用户定义的类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

本节向您介绍包。在定义了这个术语并解释了为什么包名必须是唯一的之后,本节介绍了 package 和 import 语句。接下来解释了 JVM 如何搜索包和类型,然后给出了一个展示如何使用包的例子。本节最后向您展示如何将一个类文件包封装到 JAR 文件中。

images 提示除了最普通的顶级类型和(通常)那些作为应用入口点的类,你应该考虑将你的类型(尤其是如果它们是可重用的)存储在包中。

什么是套餐?

一个是一个惟一的名称空间,可以包含顶级类、其他顶级类型和子包的组合。只有声明为public的类型才能从包外部访问。此外,描述类接口的常量、构造函数、方法和嵌套类型必须声明为public才能从包外访问。

images 注意在本书中,我通常不声明顶级类型及其可访问成员public,除非我正在创建一个包。

每个包都有一个名称,它必须是一个不可保留的标识符。成员访问操作符将包名与子包名分开,并将包或子包名与类型名分开。例如,graphics.shapes.Circle中的两个成员访问操作符将包名graphicsshapes子包名分开,并将子包名shapesCircle类型名分开。

images 标准类库将其众多类和其他顶级类型组织成多个包。这些包中有许多是标准java包的子包。例子有java.io(与输入/输出操作相关的类型)java.lang(面向语言的类型)java.lang.reflect(面向反射的语言类型)java.net(面向网络的类型)java.util(工具类型)。

包名必须是唯一的

假设您有两个不同的graphics.shapes包,并且假设每个shapes子包包含一个具有不同接口的Circle类。当编译器在源代码中遇到System.out.println(new Circle(10.0, 20.0, 30.0).area());时,它需要验证area()方法是否存在。

编译器将搜索所有可访问的包,直到找到包含一个Circle类的graphics.shapes包。如果找到的包包含适当的带有area()方法的Circle类,一切正常;否则,如果Circle类没有area()方法,编译器会报告一个错误。

这个场景说明了选择唯一的包名的重要性。具体来说,顶层包名必须是唯一的。选择这个名字的惯例是取你的互联网域名,然后反过来。例如,我会选择ca.tutortutor作为我的顶级包名,因为tutortutor.ca是我的域名。然后我会指定ca.tutortutor.graphics.shapes.Circle来访问Circle

images 注意反向互联网域名并不总是有效的包名。它的一个或多个组件名可能以数字(6.com)开头,包含连字符(-)或其他非法字符(aq-x.com),或者是 Java 的保留字之一(int.com)。惯例要求在数字前面加上下划线(com._6),用下划线(com.aq_x)替换非法字符,用下划线(com.int_)作为保留字的后缀。

套餐声明

package 语句标识源文件的类型所在的包。该语句由保留字package组成,后面是成员访问操作符分隔的包和子包名称列表,后面是分号。

例如,package graphics;指定源文件的类型位于名为graphics的包中,package graphics.shapes;指定源文件的类型位于graphics包的shapes子包中。

按照惯例,包名用小写表示。如果名称由多个单词组成,除了第一个单词以外,每个单词都要大写。

源文件中只能出现一个 package 语句。当它存在时,除了注释之外,在该语句之前不能有任何内容。

images 注意在源文件中指定多个 package 语句或在 package 语句上方放置除注释之外的任何内容都会导致编译器报告错误。

Java 实现将包和子包的名称映射到同名的目录。例如,一个实现会将graphics映射到一个名为graphics的目录,并将graphics.shapes映射到graphics的一个shapes子目录。Java 编译器将实现包类型的类文件存储在相应的目录中。

images 注意如果一个源文件不包含包语句,那么这个源文件的类型就被认为属于未命名包。这个包对应于当前目录。

进口声明

想象一下,在源代码中,必须为该类型的每次出现重复指定ca.tutortutor.graphics.shapes.Circle或其他冗长的包限定类型名。Java 提供了一种替代方法,让您不必指定包的细节。这个替代语句就是 import 语句。

import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。该语句由保留字import组成,后面是成员访问操作符分隔的包和子包名称列表,后面是类型名或*(星号),后面是分号。

*符号是一个通配符,代表所有非限定的类型名。它告诉编译器在 import 语句的指定包中查找这样的名称,除非在以前搜索的包中找到了类型名。(使用通配符不会降低性能,也不会导致代码膨胀,但是会导致名称冲突,您将会看到这一点。)

例如,import ca.tutortutor.graphics.shapes.Circle;告诉编译器在ca.tutortutor.graphics.shapes包中存在一个不合格的Circle类。类似地,import ca.tutortutor.graphics.shapes.*;告诉编译器如果遇到Rectangle类、Triangle类,甚至Employee类(如果还没有找到Employee,就在这个包中查找。

images 提示您应该避免使用*通配符,这样其他开发人员可以很容易地看到源代码中使用了哪些类型。

因为 Java 是区分大小写的,所以在 import 语句中指定的包和子包名称的大小写必须与 package 语句中使用的大小写相同。

当导入语句出现在源代码中时,只有包语句和注释可以在它们之前。

images 注意在导入语句上放置除了 package 语句、import 语句、static import 语句(稍后讨论)和注释之外的任何内容都会导致编译器报告错误。

当使用通配符版本的 import 语句时,您可能会遇到名称冲突,因为任何非限定的类型名都与通配符匹配。例如,您有graphics.shapesgeometry包,每个包包含一个Circle类,源代码以import geometry.*;import graphics.shape.*;语句开始,并且它还包含一个未限定的Circle事件。因为编译器不知道Circle是指geometryCircle类还是graphics.shapeCircle类,所以它会报告一个错误。您可以通过用正确的包名限定Circle来解决这个问题。

images 注意编译器自动从java.lang包中导入String类和其他类型,这就是为什么不需要用java.lang限定String的原因。

搜索包和类型

第一次开始使用包的 Java 新手经常会因为“没有找到类定义”和其他错误而感到沮丧。通过理解 JVM 如何搜索包和类型,可以部分避免这种挫折。

本节解释了搜索过程的工作原理。要理解这个过程,您需要认识到编译器是一个特殊的 Java 应用,它在 JVM 的控制下运行。此外,还有两种不同形式的搜索。

编译时搜索

当编译器在源代码中遇到类型表达式(如方法调用)时,它必须找到该类型的声明,以验证表达式是合法的(例如,类型的类中存在一个方法,其参数类型与方法调用中传递的参数类型相匹配)。

编译器首先搜索 Java 平台包(包含类库类型)。然后它搜索扩展包(寻找扩展类型)。如果在启动 JVM 时指定了-sourcepath命令行选项(通过javac,编译器将搜索指定路径的源文件。

images 注意 Java 平台的包存储在rt.jar和其他几个重要的 JAR 文件中。扩展包存储在一个名为ext的特殊扩展目录中。

否则,编译器会在用户类路径中(按从左到右的顺序)搜索包含该类型的第一个用户类文件或源文件。如果没有用户类路径,则搜索当前目录。如果没有包匹配或者仍然找不到类型,编译器会报告一个错误。否则,编译器会将包信息记录在类文件中。

images 注意用户类路径是通过用于启动 JVM 的-classpath选项指定的,或者如果不存在的话,通过CLASSPATH环境变量指定。

运行时搜索

当编译器或任何其他 Java 应用运行时,JVM 将遇到类型,并且必须通过称为类加载器的特殊代码加载它们相关的类文件(在附录 C 中讨论)。JVM 将使用先前存储的与遇到的类型相关联的包信息来搜索该类型的类文件。

JVM 搜索 Java 平台包,然后是扩展包,接着是用户类路径(从左到右的顺序),以找到包含该类型的第一个类文件。如果没有用户类路径,则搜索当前目录。如果没有匹配的包或找不到类型,则报告“找不到类定义”错误。否则,类文件被加载到内存中。

images 注意无论是使用-classpath选项还是CLASSPATH环境变量来指定用户类路径,都有一个必须遵循的特定格式。在 Windows 下,这种格式表示为path1;path2;...,其中path1path2等是包目录的位置。在 Unix 和 Linux 下,这种格式变为path1:path2:...

玩包裹

假设您的应用需要将消息记录到控制台、文件或另一个目的地。它可以在日志库的帮助下完成这项任务。我对这个库的实现由一个名为Logger的接口、一个名为LoggerFactory的抽象类和一对名为ConsoleFile的包私有类组成。

images 注意我介绍的日志库是抽象工厂设计模式的一个例子,它在 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides (Addison-Wesley,1995;ISBN: 0201633612)。

清单 3-17 展示了Logger接口,它描述了记录消息的对象。

清单 3-17。描述通过Logger接口记录消息的对象

package logging;

public interface Logger
{
   boolean connect();
   boolean disconnect();
   boolean log(String msg);
}

每个connect()disconnect()log()方法在成功时返回 true,在失败时返回 false。(在本章的后面,你会发现一种处理失败的更好的技巧。)这些方法没有被显式声明public,因为接口的方法是隐式的public

清单 3-18 展示了LoggerFactory抽象类。

清单 3-18。获得一个日志记录器,用于将消息记录到特定目的地

package logging;

public abstract class LoggerFactory
{
   public final static int CONSOLE = 0;
   public final static int FILE = 1;
   public static Logger newLogger(int dstType, String... dstName)
   {
      switch (dstType)
      {
         case CONSOLE: return new Console(dstName.length == 0 ? null
                                                              : dstName[0]);
         case FILE   : return new File(dstName.length == 0 ? null
                                                           : dstName[0]);
         default     : return null;
      }
   }
}

newLogger()返回一个Logger实例,用于将消息记录到适当的目的地。它使用可变数量的参数特性(参见第二章)来选择性地接受额外的String参数,用于那些需要该参数的目的地类型。例如,FILE需要一个文件名。

清单 3-19 展示了包私有的Console类——这个类在logging包中的类之外是不可访问的,因为保留字class前面没有保留字public

清单 3-19。将消息记录到控制台

package logging;

class Console implements Logger
{
   private String dstName;
   Console(String dstName)
   {
      this.dstName = dstName;
   }
   @Override
   public boolean connect()
   {
      return true;
   }
   @Override
   public boolean disconnect()
   {
      return true;
   }
   @Override
   public boolean log(String msg)
   {
      System.out.println(msg);
      return true;
   }
}

Console的 package-private 构造函数保存它的参数,很可能是null,因为不需要String参数。也许Console的未来版本会使用这个参数来标识多个控制台窗口中的一个。

清单 3-20 展示了包私有的File类。

清单 3-20。将消息记录到文件中(最终)

package logging;

class File implements Logger
{
   private String dstName;
   File(String dstName)
   {
      this.dstName = dstName;
   }
   @Override
   public boolean connect()
   {
      if (dstName == null)
         return false;
      System.out.println("opening file "+dstName);
      return true;
   }
   @Override
   public boolean disconnect()
   {
      if (dstName == null)
         return false;
      System.out.println("closing file "+dstName);
      return true;
   }
   @Override
   public boolean log(String msg)
   {
      if (dstName == null)
         return false;
      System.out.println("writing "+msg+" to file "+dstName);
      return true;
   }
}

Console不同,File需要一个 onnull 参数。每个方法首先验证这个参数不是null。如果参数是null,方法返回 false 表示失败。(在第八章的中,我重构了File,加入了适当的文件编写代码。)

日志库允许我们在应用中引入可移植的日志代码。除了对newLogger()的调用之外,该代码将保持不变,而不管日志记录的目的地是哪里。清单 3-21 展示了一个测试这个库的应用。

清单 3-21。测试测井库

import logging.Logger;
import logging.LoggerFactory;

class TestLogger
{
   public static void main(String[] args)
   {
      Logger logger = LoggerFactory.newLogger(LoggerFactory.CONSOLE);
      if (logger.connect())
      {
         logger.log("test message #1");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to console-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE, "x.txt");
      if (logger.connect())
      {
         logger.log("test message #2");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
      logger = LoggerFactory.newLogger(LoggerFactory.FILE);
      if (logger.connect())
      {
         logger.log("test message #3");
         logger.disconnect();
      }
      else
         System.out.println("cannot connect to file-based logger");
   }
}

按照以下步骤(假设已经安装了 JDK)创建logging包和TestLogger应用,并运行这个应用:

  1. 创建一个新目录,并使该目录成为当前目录。
  2. 在当前目录下创建一个logging目录。
  3. 将清单 3-17 复制到logging目录下一个名为Logger.java的文件中。
  4. 将清单 3-18 复制到logging目录下一个名为LoggerFactory.java的文件中。
  5. 将清单 3-19 复制到logging目录下一个名为Console.java的文件中。
  6. 将清单 3-20 复制到logging目录下一个名为File.java的文件中。
  7. 将清单 3-21 中的复制到当前目录下一个名为TestLogger.java的文件中。
  8. 执行javac TestLogger.java,它也编译logger的源文件。
  9. 执行java TestLogger

完成最后一步后,您应该观察到来自TestLogger应用的以下输出:

test message #1
opening file x.txt
writing test message #2 to file x.txt
closing file x.txt
cannot connect to file-based logger

logging被移动到另一个位置时会发生什么?例如,将logging移动到根目录并运行TestLogger。现在,您将看到一条错误消息,提示 JVM 没有找到logging包及其LoggerFactory类文件。

您可以通过在运行java工具时指定-classpath或者将logging包的位置添加到CLASSPATH环境变量中来解决这个问题。您可能会发现使用前一个选项更方便,如以下特定于 Windows 的命令行所示:

java -classpath \;. TestLogger

反斜杠代表 Windows 中的根目录。(我可以指定一个正斜杠作为替代。)此外,句点代表当前目录。如果它丢失了,JVM 会报错没有找到TestLogger类文件。

images 提示如果您发现一条错误消息,其中 JVM 报告它找不到应用类文件,请尝试在类路径后面附加一个句点字符。这样做可能会解决问题。

包和 JAR 文件

第一章简单介绍了一下 JDK 的jar工具,它用于归档 JAR 文件中的类文件,也用于提取 JAR 文件的类文件。您可以将包存储在 JAR 文件中,这可能不足为奇,因为这极大地简化了基于包的类库的分发。

为了向您展示在 JAR 文件中存储一个包是多么容易,我们将创建一个包含logging包的四个类文件(Logger.classLoggerFactory.classConsole.classFile.class)的logger.jar文件。完成以下步骤来完成此任务:

  1. 确保当前目录包含先前创建的logging目录及其四个类文件。
  2. 执行jar cf logger.jar logging\*.class。您也可以执行jar cf logger.jar logging/*.class。(c选项代表“创建新的归档文件”,而f选项代表“指定归档文件的文件名”)

现在,您应该在当前目录中找到一个logger.jar文件。为了证明这个文件包含四个类文件,执行jar tf logger.jar。(t选项代表“目录列表”)

您可以通过将logger.jar添加到类路径中来运行TestLogger.class。比如你可以通过java -classpath logger.jar;. TestLogger在 Windows 下运行TestLogger

静态导入

接口应该只用于声明类型。然而,一些开发人员违反了这一原则,使用接口只导出常量。这样的接口被称为常量接口,清单 3-22 中的给出了一个例子。

清单 3-22。声明一个常量接口

interface Directions
{
   int NORTH = 0;
   int SOUTH = 1;
   int EAST = 2;
   int WEST = 3;
}

采用常量接口的开发人员这样做是为了避免在常量名称前加上其类名(如在Math.PI中,其中PIjava.lang.Math类中的常量)。他们通过实现接口来做到这一点——参见清单 3-23 。

清单 3-23。实现一个常量接口

class TrafficFlow implements Directions
{
   public static void main(String[] args)
   {
      showDirection((int)(Math.random()*4));
   }
   static void showDirection(int dir)
   {
      switch (dir)
      {
         case NORTH: System.out.println("Moving north"); break;
         case SOUTH: System.out.println("Moving south"); break;
         case EAST : System.out.println("Moving east"); break;
         case WEST : System.out.println("Moving west");
      }
   }
}

清单 3-23 的TrafficFlow类实现Directions的唯一目的是不必指定Directions.NORTHDirections.SOUTHDirections.EASTDirections.WEST

这是一个令人震惊的接口误用。这些常量只不过是一个实现细节,不允许泄露到类的导出接口中,因为它们可能会使类的用户困惑(这些常量的目的是什么?).此外,它们代表了未来的承诺:即使当类不再使用这些常量时,接口也必须保留以确保二进制兼容性。

Java 5 引入了一种替代方案,既满足了对常量接口的需求,又避免了它们的问题。这个静态导入特性允许您导入一个类的static成员,这样您就不必用它们的类名来限定它们。它是通过对 import 语句的一个小修改来实现的,如下所示:

import static *packagespec* . *classname* . ( *staticmembername* | * );

静态导入语句在import后指定static。然后,它指定一个成员访问操作符分隔的包和子包名称列表,后面是成员访问操作符和类名。再次指定成员访问操作符,后跟一个静态成员名或星号通配符。

images 注意在静态导入语句上放置除了 package 语句、import/static import 语句和注释之外的任何内容都会导致编译器报告错误。

您可以指定一个静态成员名称,以便只导入该名称:

import static java.lang.Math.PI;  // Import the PI static field only.
import static java.lang.Math.cos; // Import the cos() static method only.

相反,您可以指定通配符来导入所有静态成员名称:

import static java.lang.Math.*;   // Import all static members from Math.

现在,您可以引用静态成员,而不必指定类名:

System.out.println(cos(PI));

使用多个静态导入语句会导致名称冲突,从而导致编译器报告错误。例如,假设您的geom包包含一个带有名为PI的静态成员的Circle类。现在假设你在源文件的顶部指定了import static java.lang.Math.*;import static geom.Circle.*;。最后,假设您在该文件的代码中指定了System.out.println(PI);。编译器报告一个错误,因为它不知道PI是属于Math还是Circle

异常情况

在理想世界中,应用运行时不会发生任何不好的事情。例如,当应用需要打开文件时,文件总是存在的,应用总是能够连接到远程计算机,当应用需要实例化对象时,JVM 会耗尽内存。

相比之下,现实世界中的应用偶尔会尝试打开不存在的文件,尝试连接到无法与之通信的远程计算机,并且需要比 JVM 所能提供的更多的内存。您的目标是编写适当响应这些和其他异常情况(异常)的代码。

本节将向您介绍异常。在定义了这个术语之后,本节将着眼于在源代码中表示异常。然后讨论抛出和处理异常的主题,最后讨论如何在方法返回之前执行清理任务,不管是否抛出了异常。

什么是例外?

一个异常是与应用正常行为的背离。例如,应用试图打开一个不存在的文件进行读取。正常行为是成功打开文件并开始读取其内容。但是,如果文件不存在,则无法读取该文件。

这个例子说明了一个不可避免的异常。然而,一个变通办法是可能的。例如,应用可以检测到该文件不存在,并采取替代的操作过程,这可能包括告诉用户该问题。不可避免的例外情况,如果有可能的解决办法,一定不能忽视。

由于代码编写得不好,可能会出现异常。例如,应用可能包含访问数组中每个元素的代码。由于疏忽,数组访问代码可能试图访问一个不存在的数组元素,从而导致异常。这种异常可以通过编写正确的代码来避免。

最后,可能会发生无法阻止的异常,并且没有解决方法。例如,JVM 可能会耗尽内存,或者可能找不到类文件。这种被称为错误的异常非常严重,以至于无法(或者至少是不可取的)解决;应用必须终止,向用户显示一条消息,解释它终止的原因。

表示源代码中的异常

异常可以通过错误代码或对象来表示。在讨论了每一种表示并解释了为什么对象更优越之后,我将向您介绍 Java 的异常和错误类层次结构,强调检查异常和运行时异常之间的区别。我通过讨论自定义异常类来结束关于在源代码中表示异常的讨论。

错误代码与对象

在源代码中表示异常的一种方法是使用错误代码。例如,一个方法可能在成功时返回 true,在发生异常时返回 false。或者,一个方法可能在成功时返回 0,并返回一个非零的整数值来标识特定类型的异常。

开发人员传统上设计方法来返回错误代码;我在清单 3-17 的Logger接口中的三个方法中的每一个中展示了这一传统。每个方法在成功时返回 true,或者返回 false 来表示异常(例如,无法连接到记录器)。

尽管必须检查方法的返回值以确定它是否代表异常,但是错误代码很容易被忽略。例如,懒惰的开发人员可能会忽略来自Loggerconnect()方法的返回代码,并试图调用log()。忽略错误代码是发明一种处理异常的新方法的原因之一。

这种新方法是基于对象的。当异常发生时,表示异常的对象由异常发生时正在运行的代码创建。描述异常周围上下文的详细信息存储在对象中。稍后将检查这些细节以解决异常。

然后对象被抛出,或者交给 JVM 去搜索一个处理程序,可以处理异常的代码。(如果异常是一个错误,应用不应该提供一个处理程序,因为错误是如此严重(例如,JVM 已经用完了内存)以至于实际上对它们无能为力。)当处理程序被定位时,它的代码被执行以提供一个解决方法。否则,JVM 终止应用。

images 注意处理异常的代码可能是错误的来源,因为它通常没有经过彻底的测试。请务必测试任何处理异常的代码。

除了太容易被忽略之外,错误代码的布尔值或整数值还不如对象名有意义。比如fileNotFound不言自明,但是false是什么意思?此外,对象可以包含导致异常的信息。这些细节有助于找到合适的解决方法。

可抛出的类层次结构

Java 提供了表示不同类型异常的类的层次结构。这些类根植于java.lang.Throwable,是所有可抛出对象(异常和错误对象——简称为异常和错误——可以被抛出)的最终超类。表 3-1 标识并描述了大多数Throwable的构造函数和方法。

images

一个类的公共方法调用抛出各种异常的助手方法并不少见。公共方法可能不会记录从助手方法抛出的异常,因为它们是实现细节,通常对公共方法的调用方是不可见的。

但是,因为此异常可能有助于诊断问题,所以公共方法可以将较低级别的异常包装在公共方法的契约接口中记录的较高级别的异常中。包装的异常被称为原因,因为它的存在导致更高级别的异常被抛出。原因是通过调用Throwable(Throwable cause)Throwable(String message, Throwable cause)构造函数创建的,它们调用initCause()方法来存储原因。如果不调用任何一个构造函数,也可以直接调用initCause(),但是必须在创建 throwable 之后立即调用。调用getCause()方法返回原因。

当一个异常引发另一个异常时,通常会捕获第一个异常,然后抛出第二个异常作为响应。换句话说,这两个例外之间存在因果联系。相比之下,有些情况下,两个独立的异常可以在兄弟代码块中抛出;例如,在 try-with-resources 语句的 try 块(将在本章后面讨论)和编译器生成的关闭资源的 finally 块中。在这些情况下,只能传播一个抛出的异常。

在 try-with-resources 语句中,当有两个这样的异常时,来自 try 块的异常被传播,并且来自 finally 块的异常被添加(通过addSuppressed()方法)到被来自 try 块的异常抑制的异常列表中。当异常展开堆栈时,它会累积多个隐藏的异常。可以通过调用getSuppressed()来检索隐藏表达式的数组。

当抛出异常时,它会留下一堆未完成的方法调用。Throwable的构造函数调用fillInStackTrace()记录这个栈跟踪信息,通过调用printStackTrace()输出。

getStackTrace()方法通过以一组java.lang.StackTraceElement实例的形式返回信息来提供对堆栈跟踪的编程访问——每个实例代表一个堆栈条目。StackTraceElement提供返回堆栈跟踪信息的方法。例如,String getMethodName()返回一个未完成方法的名称。

setStackTrace()方法是为远程过程调用(RPC)框架(RPC 在第十一章中简要讨论)和其他高级系统设计的,允许客户端在构造 throwable 时覆盖由fillInStackTrace()生成的默认堆栈跟踪,或者在从序列化流中读取 throwable 时反序列化。(我将在第八章的中讨论连载。)

除了Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)之外,每个Throwable构造器总是将抑制视为启用,并且总是调用fillInStackTrace()。相比之下,这个构造函数允许您通过将false传递给enableSuppression来禁用抑制,并通过将false传递给writableStackTrace来防止fillInStackTrace()被调用。当您计划覆盖默认堆栈跟踪并希望避免不必要的fillInStackTrace()方法调用时,将false传递给writableStackTrace。类似地,当重复捕捉和重新抛出同一个异常对象时(例如实现两个子系统之间的控制流)或者在其他异常情况下,传递falseenableSuppression

您会注意到,Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace)被表示为一个protected构造函数。此外,它的 Java 文档包括下面的句子:“Throwable的子类应该记录任何禁用抑制的情况和堆栈跟踪不可写的情况。”这是一个“类扩展的设计和文档”的例子,我将在第二章的中讨论。

沿着 throwable 层次向下,您会遇到java.lang.Exceptionjava.lang.Error类,它们分别代表异常和错误。每个类都提供了五个构造函数,将它们的参数传递给它们的Throwable对手,但是除了那些从Throwable继承的方法之外,没有提供其他方法。

Exception本身又被java.lang.CloneNotSupportedException(在第二章中讨论过)、java.io.IOException(在第八章中讨论过)和其他类所子类化。类似地,Error本身又被java.lang.AssertionError(将在本章后面讨论)、java.lang.OutOfMemoryError和其他类子类化。

images 注意曾经实例化过ThrowableException或者Error。产生的对象没有意义,因为它们太普通了。

检查异常与运行时异常

一个检查异常是一个异常,它代表了一个可能恢复的问题,开发者必须提供一个解决方法。编译器检查(检验)代码,以确保异常在抛出它的方法中得到处理,或者被显式标识为在其他地方得到处理。

Exception和除了java.lang.RuntimeException之外的所有子类(及其子类)描述了被检查的异常。例如,CloneNotSupportedExceptionIOException类描述了被检查的异常。(CloneNotSupportedException)不应该被检查,因为对于这种异常没有运行时解决方法。)

运行时异常是一个代表编码错误的异常。这种异常也被称为未检查异常,因为它不需要被处理或显式识别——错误必须被修复。因为这些异常可能在许多地方发生,所以强制处理它们会很麻烦。

RuntimeException及其子类描述未检查的异常。例如,java.lang.ArithmeticException描述的是整数被零除等算术问题。再比如java.lang.ArrayIndexOutOfBoundsException。(事后看来,RuntimeException应该被命名为UncheckedException,因为所有的异常都发生在运行时。)

images 注意许多开发人员不喜欢检查异常,因为处理它们涉及到很多工作。当库提供的方法应该抛出未检查的异常时,却抛出已检查的异常,这使得问题变得更加严重。因此,许多现代语言只支持未检查的异常。

自定义异常类

您可以声明自己的异常类。在这样做之前,问问你自己,Java 的标准类库中现有的异常类是否满足你的需要。如果你找到一个合适的类,你应该重用它。(为什么要多此一举?)其他开发人员将已经熟悉现有的类,这些知识将使您的代码更容易学习。

如果没有现有的类满足你的需求,考虑一下是子类化Exception还是RuntimeException。换句话说,您的异常类是被选中还是未被选中?根据经验,如果你认为RuntimeException将描述一个编码错误,你的类应该继承它。

images 提示命名类时,遵循提供一个Exception后缀的惯例。这个后缀表明你的类描述了一个异常。

假设您正在创建一个Media类,它的静态方法执行各种面向媒体的工具任务。例如,一种方法是将 on-MP3 媒体格式的声音文件转换成 MP3 格式。此方法将被传递源文件和目标文件参数,并将源文件转换为目标文件扩展名所暗示的格式。

在执行转换之前,该方法需要验证源文件的格式是否与其文件扩展名所暗示的格式一致。如果没有协议,就必须抛出一个异常。此外,这个异常必须存储预期的和现有的媒体格式,以便处理程序在向用户显示消息时可以识别它们。

因为 Java 的类库没有提供合适的异常类,所以你决定引入一个名为InvalidMediaFormatException的类。检测到无效的媒体格式并不是编码错误的结果,因此您还决定扩展Exception来指示异常已被检查。清单 3-24 展示了这个类的声明。

清单 3-24。声明自定义异常类

package media;

public class InvalidMediaFormatException extends Exception
{
   private String expectedFormat;
   private String existingFormat;
   public InvalidMediaFormatException(String expectedFormat,
                                      String existingFormat)
   {
      super("Expected format: "+expectedFormat+", Existing format: "+
            existingFormat);
      this.expectedFormat = expectedFormat;
      this.existingFormat = existingFormat;
   }
   public String getExpectedFormat()
   {
      return expectedFormat;
   }
   public String getExistingFormat()
   {
      return existingFormat;
   }
}

InvalidMediaFormatException提供了一个构造函数,该构造函数调用Exceptionpublic Exception(String message)构造函数,并提供了一个包含预期格式和现有格式的详细消息。在详细消息中捕获这样的细节是明智的,因为导致异常的问题可能很难重现。

InvalidMediaFormatException还提供了返回这些格式的getExpectedFormat()getExistingFormat()方法。也许处理程序会在消息中向用户提供这些信息。与详细消息不同,该消息可能是本地化的,以用户语言(法语、德语、英语等)表达。

抛出异常

现在您已经创建了一个InvalidMediaFormatException类,您可以声明Media类并开始编写它的convert()方法。此方法的初始版本验证其参数,然后验证源文件的媒体格式是否与其文件扩展名所暗示的格式一致。查看清单 3-25 。

清单 3-25。convert()方法中抛出异常

package media;

import java.io.IOException;

public **final** class Media
{
   public static void convert(String srcName, String dstName)
      throws InvalidMediaFormatException, IOException
   {
      if (srcName == null)
         throw new ullPointerException(srcName+" is ull");
      if (dstName == null)
         throw new nullPointerException(dstName+" is null");
      // Code to access source file and verify that its format matches the
      // format implied by its file extension.
      //
      // Assume that the source file's extension is RM (for Real Media) and
      // that the file's internal signature suggests that its format is
      // Microsoft WAVE.
      String expectedFormat = "RM";
      String existingFormat = "WAVE";
      throw new InvalidMediaFormatException(expectedFormat, existingFormat);
   }
}

清单 3-25 将Media类声明为final,因为这个类将只包含类方法,没有理由扩展它。

Mediaconvert()方法将throws InvalidMediaFormatException, IOException附加到它的头部。一个 throws 子句标识所有被检查的异常,这些异常被抛出方法,并且必须由一些其他方法处理。它由保留字throws组成,后跟一个逗号分隔的检查过的异常类名列表,并且总是被附加到方法头。convert()方法的 throws 子句表明该方法能够向 JVM 抛出一个InvalidMediaFormatExceptionIOException实例。

convert()还演示了 throw 语句,它由保留字throw后跟一个Throwable或子类的实例组成。(您通常实例化一个Exception子类。)该语句将实例抛出给 JVM,然后 JVM 搜索合适的处理程序来处理异常。

throw 语句的第一个用途是当空引用作为源或目标文件名传递时抛出一个java.lang.NullPointerException实例。这种未经检查的异常通常被抛出,以指示通过传递的空引用违反了协定。(第六章对java.util.Objects类的讨论提出了另一种处理传递给参数的空引用的方法。)例如,您不能将空文件名传递给convert()

throw 语句的第二个用途是当预期的媒体格式与现有格式不匹配时抛出一个media.InvalidMediaFormatException实例。在这个虚构的例子中,抛出了异常,因为预期的格式是 RM,而现有的格式是 WAVE。

InvalidMediaFormatException不同,NullPointerException没有列在convert()的 throws 子句中,因为NullPointerException实例没有被检查。它们可能发生得如此频繁,以至于迫使开发人员正确处理这些异常的负担太重。相反,开发人员应该编写尽量减少这种情况发生的代码。

尽管没有从convert()抛出,IOException还是被列在这个方法的 throws 子句中,为重构这个方法做准备,以便在文件处理代码的帮助下执行转换。

是一种异常,当一个参数被证明无效时抛出。java.lang.IllegalArgumentException类概括了非法参数场景,以包括其他类型的非法参数。例如,当数字参数为负时,下面的方法抛出一个IllegalArgumentException实例:

public static double sqrt(double x)
{
   if (x < 0)
      throw new IllegalArgumentException(x+" is negative");
   // Calculate the square root of x.
}

使用 throws 子句和 throw 语句时,还需要记住一些其他事项:

  • 您可以将 throws 子句追加到构造函数中,并在构造函数执行过程中出错时抛出异常。将不会创建结果对象。
  • 当应用的main()方法抛出异常时,JVM 终止应用并调用异常的printStackTrace()方法,将抛出异常时等待完成的嵌套方法调用序列打印到控制台。
  • 如果超类方法声明了一个 throws 子句,则重写子类方法不必声明 throws 子句。但是,如果子类方法确实声明了 throws 子句,则该子句不得包含未包含在超类方法的 throws 子句中的已检查异常类的名称,除非它们是异常子类的名称。例如,给定超类方法void foo() throws IOException {},覆盖子类方法可以声明为void foo() {}void foo() throws IOException {}void foo() throws FileNotFoundExceptionjava.io.FileNotFoundException类的子类IOException
  • 当检查的异常类名的超类名出现时,它不需要出现在 throws 子句中。
  • 当方法引发已检查的异常,并且不处理该异常或在其 throws 子句中列出该异常时,编译器会报告错误。
  • 不要在 throws 子句中包含未检查的异常类的名称。这些名称不是必需的,因为这样的异常应该会发生。此外,它们只会弄乱源代码,并可能使试图理解这些代码的人感到困惑。
  • 你可以在一个方法的 throws 子句中声明一个检查过的异常类名,而不用从该方法中抛出这个类的实例。(也许这个方法还没有完全编码。)但是,Java 要求您提供代码来处理这个异常,即使它没有被抛出。

处理异常

方法通过指定包含一个或多个适当 catch 块的 try 语句来指示其处理一个或多个异常的意图。try 语句由保留字try组成,后跟一个大括号分隔的主体。将引发异常的代码放入该块中。

catch 块由保留字catch组成,后面是圆括号分隔的单参数列表,指定异常类名,后面是大括号分隔的主体。您将处理异常的代码放置在此块中,这些异常的类型与 catch 块的参数列表的异常类参数的类型相匹配。

catch 块紧跟在 try 块之后指定。当抛出异常时,JVM 通过首先检查 catch 块来搜索处理程序,以查看它的参数类型是否与抛出的异常的超类类型相匹配。

如果找到了 catch 块,它的主体就会执行,并处理异常。否则,JVM 将继续执行方法调用堆栈,查找 try 语句包含适当 catch 块的第一个方法。除非找到 catch 块或者执行离开了main()方法,否则这个过程会继续。

以下示例说明了 try and catch:

try
{
   int x = 1/0;
}
catch (ArithmeticException ae)
{
   System.out.println("attempt to divide by zero");
}

当执行进入 try 块时,会尝试将整数 1 除以整数 0。JVM 通过实例化ArithmeticException并抛出这个异常来响应。然后它检测 catch 块,该块能够处理抛出的ArithmeticException对象,并将执行转移到该块,该块调用System.out.println()来输出合适的消息——异常被处理。

因为ArithmeticException是未检查异常类型的一个例子,并且因为未检查异常表示必须修复的编码错误,所以您通常不会捕捉到它们,如前所述。相反,您应该修复导致抛出异常的问题。

images 提示您可能希望使用上一节中显示的缩写样式来命名 catch 块参数。这种约定不仅会产生更有意义的面向异常的参数名(ae意味着已经抛出了一个ArithmeticException对象),而且有助于减少编译器错误。例如,为了方便起见,通常将 catch 块的参数命名为e。(为什么要打长名字?)然而,当先前声明的局部变量或参数也使用e作为其名称时,编译器将报告错误——多个同名的局部变量和参数不能存在于同一个范围内。

处理多种异常类型

可以在 try 块后指定多个 catch 块。例如,清单 3-25 的convert()方法指定了一个 throws 子句,表示convert()可以抛出当前抛出的InvalidMediaFormatException,以及重构convert()时将抛出的IOException。这种重构将导致convert()在无法从源文件读取或写入目标文件时抛出IOException,在无法打开源文件或创建目标文件时抛出FileNotFoundException(IOException的子类)。所有这些异常都必须处理,如清单 3-26 所示。

清单 3-26。处理不同种类的异常

import java.io.FileNotFoundException;
import java.io.IOException;

import media.InvalidMediaFormatException;
import media.Media;

class Converter
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Converter srcfile dstfile");
         return;
      }
      try
      {
         Media.convert(args[0], args[1]);
      }
      catch (InvalidMediaFormatException imfe)
      {
         System.out.println("Unable to convert "+args[0]+" to "+args[1]);
         System.out.println("Expecting "+args[0]+" to conform to "+
                            imfe.getExpectedFormat()+" format.");
         System.out.println("However, "+args[0]+" conformed to "+
                            imfe.getExistingFormat()+" format.");
      }
      catch (FileNotFoundException fnfe)
      {
      }
      catch (IOException ioe)
      {
      }
   }
}

对清单 3-26 中Mediaconvert()方法的调用被放在一个 try 块中,因为这个方法能够抛出被检查的InvalidMediaFormatExceptionIOExceptionFileNotFoundException类的一个实例——被检查的异常必须通过附加到该方法的 throws 子句来处理或声明抛出。

catch (InvalidMediaFormatException imfe)块的语句旨在向用户提供一条描述性的错误消息。更复杂的应用会将这些名称本地化,以便用户可以用自己的语言阅读消息。不输出面向开发人员的详细消息,因为在这个普通的应用中不需要。

images 注意面向开发人员的详细消息通常不会本地化。而是用开发者的语言来表达。用户应该可以看到详细消息。

虽然没有抛出,但是需要一个针对IOException的 catch 块,因为这个被检查的异常类型出现在convert()的 throws 子句中。因为catch (IOException ioe)块也可以处理抛出的FileNotFoundException实例(因为FileNotFoundExceptionIOException的子类),所以catch (FileNotFoundException fnfe)块在这一点上是不必要的,但是它的存在是为了分离一种情况的处理,即一个文件不能被打开来读或者不能被创建来写(这个问题将在convert()被重构为包含文件代码后解决)。

假设当前目录包含清单 3-26 和一个包含InvalidMediaFormatException.javaMedia.javamedia子目录,编译这个清单(javac Converter.java,它也编译media的源文件,并运行应用,如java Converter A B所示。Converter通过显示以下输出做出响应:

Unable to convert A to B
Expecting A to conform to RM format.
However, A conformed to WAVE format.

清单 3-26 的空FileNotFoundExceptionIOException catch 块说明了一个常见的问题,将 catch 块留空是因为它们不方便编码。除非有充分的理由,否则不要创建空的 catch 块。它吞掉了异常,而您不知道异常被抛出了。(为了简洁起见,我并不总是在本书的例子中编写 catch 块。)

images 注意当您在 try 主体后指定两个或更多具有相同参数类型的 catch 块时,编译器会报告错误。例子:try {} catch (IOException ioe1) {} catch (IOException ioe2) {}。您必须将这些 catch 块合并成一个块。

尽管可以按任何顺序编写 catch 块,但当一个 catch 块的参数是另一个 catch 块的参数的超类型时,编译器会限制这种顺序。子类型参数 catch 块必须在超类型参数 catch 块之前;否则,将会执行子类型参数 catch 块。

例如,FileNotFoundException catch 块必须在IOException catch 块之前。如果编译器允许先指定IOException catch 块,那么FileNotFoundException catch 块就会执行,因为FileNotFoundException实例也是其IOException超类的实例。

多批次

假设您有两个或多个代码相同或早期相同的 catch 块。为了消除这种冗余,您可能会尝试将这段代码重构为一个具有公共超类异常类型(比如catch (Exception e) {})的 catch 块。然而,捕捉过于宽泛的异常并不是一个好主意,因为这样做会掩盖处理程序的目的(例如,catch (Exception e) {}处理哪些异常)。此外,单个 catch 块可能会无意中处理应该在其他地方处理的抛出异常。(也许这些异常是重构代码的结果。)

Java 提供了 multicatch 语言特性,以避免冗余以及捕捉过于宽泛的异常所固有的问题。Multicatch 允许您在 catch 块中指定多个异常类型,其中每个后续类型都通过在这些类型之间放置竖线(|)与其前一个类型分隔开。考虑以下示例:

try
{
   Media.convert(args[0], args[1]);
}
catch (InvalidMediaFormatException | UnsupportedMediaFormatException imfeumfe)
{
   // common code to respond to these similar exceptions
}

这个例子假设convert()也能够在检测到它不能处理的媒体格式(比如视频格式)时抛出media.UnsupportedMediaFormatException。当convert()抛出InvalidMediaFormatExceptionUnsupportedMediaFormatException时,catch 块将处理任一异常。

当 catch 块的单个参数列表中列出多个异常类型时,该参数被隐式视为final。因此,您不能更改该参数的值。例如,您不能更改存储在示例的imfeumfe参数中的引用。

多批次并不总是必要的。例如,你不需要指定catch (FileNotFoundException | IOException fnfeioe) { /* suitable common code */ }来处理FileNotFoundExceptionIOException,因为catch (IOException ioe)通过捕捉FileNotFoundExceptionIOException来完成相同的任务。因此,当编译器检测到 catch 块的参数列表异常类型包括超类型和子类型时,它会报告错误。

images 注意编译一个处理多种异常类型的 catch 块产生的字节码将比编译几个 catch 块产生的字节码要小,每个 catch 块只处理一种列出的异常类型。处理多种异常类型的 catch 块在编译期间不会产生重复的字节码。换句话说,字节码不包含复制的异常处理程序。

重新抛出异常

在讨论Throwable类时,我讨论了在较高级别的异常中包装较低级别的异常。此活动通常发生在 catch 块中,如下例所示:

catch (IOException ioe)
{
   throw new ReportCreationException(ioe);
}

这个例子假设一个 helper 方法刚刚抛出了一个通用的IOException实例,作为尝试创建一个报告的结果。公共方法的契约声明在这种情况下抛出ReportCreationException。为了满足约定,抛出后一个异常。为了让负责调试有问题的应用的开发人员满意,IOException实例被包装在ReportCreationException实例中,该实例被抛出给公共方法的调用者。

有时,catch 块可能无法完全处理异常。也许它需要访问方法调用堆栈中某个祖先方法提供的信息。但是,catch 块可能能够部分处理该异常。在这种情况下,它应该部分处理异常,然后重新抛出异常,以便祖先方法中的处理程序可以完成异常的处理。下面的示例演示了这种情况:

catch (FileNotFoundException fnfe)
{
   // Provide code to partially handle the exception here.
   throw fnfe; // Rethrow the exception here.
}
最终重新抛出

Java 7 的编译器比它的前辈更精确地分析重新抛出的异常,但只有在没有给重新抛出的异常的 catch 块参数赋值时(该参数实际上是final)。当异常源自前面的 try 块并且是参数类型的超类型/子类型时,编译器抛出所捕获异常的实际类型,而不是抛出参数的类型(如以前的 Java 版本中所做的)。

这个 final rethrow 特性的目的是方便在代码块周围添加一个 try 语句来拦截、处理和重新抛出一个异常,而不会影响从代码中抛出的静态确定的异常集。此外,这个特性允许您提供一个通用的异常处理程序,在抛出异常的地方处理部分异常,并在其他地方提供更精确的处理程序来处理再次抛出的异常。考虑清单 3-27 。

清单 3-27。压力模拟

class PressureException extends Exception
{
   PressureException(String msg)
   {
      super(msg);
   }
}
class TemperatureException extends Exception
{
   TemperatureException(String msg)
   {
      super(msg);
   }
}
class MonitorEngine
{
   public static void main(String[] args)
   {
      try
      {
         monitor();
      }
      catch (Exception e)
      {
         if (e instanceof PressureException)
            System.out.println("correcting pressure problem");
         else
            System.out.println("correcting temperature problem");
      }
   }
   static void monitor() throws Exception
   {
      try
      {
         if (Math.random() < 0.1)
            throw new PressureException("pressure too high");
         else
         if (Math.random() > 0.9)
            throw new TemperatureException("temperature too high");
         else
            System.out.println("all is well");
      }
      catch (Exception e)
      {
         System.out.println(e.getMessage());
         throw e;
      }
   }
}

清单 3-27 模拟实验火箭发动机的测试,看看发动机的压力或温度是否超过安全阈值。它通过monitor()助手方法执行这个测试。

monitor()的 try 块在检测到压力极值时抛出PressureException,在检测到温度极值时抛出TemperatureException。(因为这只是模拟,所以使用随机数字。我会在第四章中对中的随机数字有更多的说明。)try 块后面是 catch 块,该块设计为通过输出警告消息来部分处理异常。然后这个异常被再次抛出,这样monitor()的调用方法就可以完成对异常的处理。

在 Java 7 之前,你不能在monitor()的 throws 子句中指定PressureExceptionTemperatureException,因为 catch 块的e参数是Exception类型的,重新抛出异常被视为抛出参数的类型。从 Java 7 开始,可以在 throws 子句中指定这些异常类型,因为编译器确定throw e抛出的异常来自 try 块,并且只有PressureExceptionTemperatureException可以从这个块抛出。

因为现在可以指定static void monitor() throws PressureException, TemperatureException,所以可以在调用monitor()的地方提供更精确的处理程序,如下例所示:

try
{
   monitor();
}
catch (PressureException pe)
{
   System.out.println("correcting pressure problem");
}
catch (TemperatureException te)
{
   System.out.println("correcting temperature problem");
}

由于 final rethrow 提供了改进的类型检查,在以前版本的 Java 下编译的源代码可能无法在 Java 7 下编译。例如,考虑清单 3-28 中的。

清单 3-28。演示最终再次抛出导致的代码破坏

class SuperException extends Exception
{
}
class SubException1 extends SuperException
{
}
class SubException2 extends SuperException
{
}
class BreakageDemo
{
   public static void main(String[] args) throws SuperException
   {
      try
      {
         throw new SubException1();
      }
      catch (SuperException se)
      {
         try
         {
            throw se;
         }
         catch (SubException2 se2)
         {
         }
      }
   }
}

清单 3-28 在 Java 6 和更早版本下编译。然而,它在 Java 7 下无法编译,Java 7 的编译器检测并报告了在相应的 try 语句体中抛出了SubException2的事实。

虽然不太可能发生,但还是有可能遇到这个问题。与其抱怨这种破坏,不如考虑让编译器检测冗余代码源的价值,删除冗余代码会产生更干净的源代码和更小的类文件。

执行清理

在某些情况下,您可能希望在执行方法的清理代码之前防止方法引发异常。例如,您可能希望关闭一个已打开但无法写入的文件,这可能是因为磁盘空间不足。Java 为这种情况提供了 finally 块。

finally 块由保留字finally组成,后面跟着一个主体,它提供了清理代码。finally 块跟在 catch 块或 try 块后面。在前一种情况下,异常在最终执行之前被处理(并且可能被重新抛出)。在后一种情况下,最终会在引发和处理异常之前执行。

清单 3-29 展示了文件复制应用环境中的 finally 块。

清单 3-29。处理抛出异常后清理

`import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

class Copy
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcfile dstfile");
         return;
      }
      FileInputStream fis = null;
      try
      {
         fis = new FileInputStream(args[0]);
         FileOutputStream fos = null;
         try
         {
            fos = new FileOutputStream(args[1]);
            int b; // I chose b instead of byte because byte is a reserved word.
            while ((b = fis.read()) != -1)
               fos.write(b);
         }
         catch (FileNotFoundException fnfe)
         {
            String msg = args[1]+" could not be created, possibly because "+
                         "it might be a directory";
            System.err.println(msg);
         }
         catch (IOException ioe)          {
            String msg = args[0]+" could not be read, or "+args[1]+
                         " could not be written";
            System.err.println(msg);
         }
         finally
         {
            if (fos != null)
               try
               {
                  fos.close();
               }
               catch (IOException ioe)
               {
                  System.err.println("unable to close "+args[1]);
               }
         }
      }
      catch (FileNotFoundException fnfe)
      {
         String msg = args[0]+" could not be found or might be a directory";
         System.err.println(msg);
      }
      finally
      {
         if (fis != null)
            try
            {
               fis.close();
            }
            catch (IOException ioe)
            {
               System.err.println("unable to close "+args[0]);
            }
      }
   }
}`

images 注意如果你发现这个清单的面向文件的代码很难掌握,不要担心;我将在第八章的中正式介绍 I/O 和清单的面向文件的类型。我在这里展示这段代码是因为文件复制提供了一个 finally 块的完美例子。

清单 3-29 展示了一个应用,它通过一对嵌套的 try 块将字节从源文件复制到目标文件。外部 try 块使用一个java.io.FileInputStream对象打开源文件进行读取;内部 try 块使用一个java.io.FileOutputStream对象来创建要写入的目标文件,并且还包含文件复制代码。

如果fis = new FileInputStream(args[0]);表达式抛出FileNotFoundException,执行将流入外部 try 语句的catch (FileNotFoundException fnfe)块,该块向用户输出合适的消息。然后执行进入外部 try 语句的 finally 块。

外部 try 语句的 finally 块关闭一个打开的源文件。然而,当抛出FileNotFoundException时,源文件没有打开——没有给fis分配引用。finally 块使用if (fis != null)来检测这种情况,并且不试图关闭文件。

如果fis = new FileInputStream(args[0]);成功,执行将流入内部 try 块,该块将执行fos = new FileOutputStream(args[1]);。如果这个表达式抛出了FileNotFoundException,执行将进入内部 try 的catch (FileNotFoundException fnfe)块,它将向用户输出一条合适的消息。

这一次,继续执行内部 try 语句的 finally 块。因为没有创建目标文件,所以没有尝试关闭该文件。相反,开放的源文件必须关闭,这是在执行从内部 finally 块移到外部 finally 块时完成的。

当文件没有打开时,FileInputStreamFileOutputStreamclose()方法抛出IOException。因为IOException被勾选,所以这些异常必须被处理;否则,有必要在main()方法头中添加一个throws IOException子句。

您可以只使用 finally 块来指定 try 语句。当您不准备处理封闭方法(或封闭 try 语句,如果存在)中的异常,但需要在引发的异常导致执行离开方法之前执行清理时,您应该这样做。清单 3-30 提供了一个演示。

清单 3-30。在处理抛出的异常之前进行清理

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

class Copy
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Copy srcfile dstfile");
         return;
      }
      try
      {
         copy(args[0], args[1]);
      }
      catch (FileNotFoundException fnfe)
      {
         String msg = args[0]+" could not be found or might be a directory,"+
                      " or "+args[1]+" could not be created, "+
                      "possibly because "+args[1]+" is a directory";
         System.err.println(msg);
      }
      catch (IOException ioe)
      {
         String msg = args[0]+" could not be read, or "+args[1]+
                      " could not be written";
         System.err.println(msg);
      }
   }
   static void copy(String srcFile, String dstFile) throws IOException
   {
      FileInputStream fis = new FileInputStream(srcFile);
      try
      {
         FileOutputStream fos = new FileOutputStream(dstFile);
         try
         {
            int b;
            while ((b = fis.read()) != -1)
               fos.write(b);
         }
         finally
         {
            try
            {
               fos.close();
            }
            catch (IOException ioe)
            {
               System.err.println("unable to close "+dstFile);
            }
         }
      }
      finally
      {
         try
         {
            fis.close();
         }
         catch (IOException ioe)
         {
            System.err.println("unable to close "+srcFile);
         }
      }
   }
}

清单 3-30 为清单 3-29 的提供了一个替代方案,试图提高可读性。它通过引入一个copy()方法来完成这个任务,该方法使用一对嵌套的 try-finally 构造来执行文件复制操作,并且无论是否抛出异常,都关闭每个打开的文件。如果FileInputStream fis = new FileInputStream(srcFile);表达式导致抛出FileNotFoundException,执行将离开copy()而不进入外部 try 语句。该语句仅在FileInputStream对象被创建后输入,表示源文件已被打开。

如果FileOutputStream fos = new FileOutputStream(dstFile);表达式导致抛出FileNotFoundException,执行将离开copy(),而不进入内部 try 语句。然而,执行仅在进入与外部 try 块配对的 finally 块之后才离开copy()。这个 finally 块关闭了打开的源文件。

如果内部 try 语句体中的read()write()方法抛出一个IOException对象,则执行与内部 try 块关联的 finally 块。这个 finally 块关闭打开的目标文件。然后,执行流入外部的 finally 块,该块关闭了开放的源文件,并从copy()继续执行。

images 注意如果 try 语句体抛出一个异常,并且如果 finally 块导致另一个异常被抛出,这个新的异常将替换之前丢失的异常。

尽管清单 3-30 比清单 3-29 更具可读性,但是由于每个 finally 块都需要一个 try 语句来关闭文件,所以仍然有很多样板文件。这个样板文件是必要的;它的移除导致一个新的IOException可能从 catch 块中被抛出,这将屏蔽先前抛出的IOException

自动资源管理

清单 3-29 和 3-30 很可怕,因为需要大量代码来确保每个文件都被关闭。然而,您不必这样编码。相反,您可以使用 Java 的 try-with-resources 语句来代表您自动关闭资源(不再需要时必须关闭的对象)。

try-with-resources 语句至少包含一个 try 块,其语法如下:

try ([*resource declaration*; ...] *resource declaration*)
{
   // code to execute
}

保留字try后面是圆括号分隔和分号分隔的资源声明列表。当执行离开 try 块时,每个声明的资源都将被关闭,无论是正常情况下还是通过抛出异常。以下示例使用 try-with-resources 来大大缩短清单 3-30 的copy()方法:

static void copy(String srcFile, String dstFile) throws IOException
{
   try (FileInputStream fis = new FileInputStream(srcFile);
        FileOutputStream fos = new FileOutputStream(dstFile))
   {
      int b;
      while ((b = fis.read()) != -1)
         fos.write(b);
   }
}

该示例的 try-with-resources 语句声明了两个必须关闭的文件资源;资源声明用强制分号分隔。当copy()方法结束(正常情况下或通过抛出的异常),调用fisfosclose()方法,但顺序与创建这些资源的顺序相反(fisfos之前创建)。因此,fos.close()fis.close()之前被调用。

假设fos.write(buffer, 0, n)抛出了一个IOException实例。现在假设幕后的fos.close()方法调用产生了一个抛出的IOException实例。后一个异常被抑制,由fos.write(buffer, 0, n)抛出的异常是由copy()方法抛出的异常。被抑制的异常可以通过调用ThrowableThrowable[] getSuppressed()方法来检索,我之前已经介绍过了。

images 注意一个 try-with-resources 语句可以包含 catch 和 finally。这些块在所有声明的资源关闭后执行。

为了在您自己的类中利用 try-with-resources,请记住资源类必须实现java.lang.AutoCloseable接口或其java.lang.Closeable子接口。每个接口都提供了一个执行关闭操作的close()方法。

Closeableclose()方法声明只抛出IOException(或子类型)不同,AutoCloseableclose()方法声明抛出Exception。因此,实现AutoCloseableCloseable或子接口的类可以声明它们的close()方法来抛出任何类型的异常。应该声明close()方法来抛出一个更具体的异常,或者(就像java.util.Scannerclose()方法一样)如果方法不会失败就不抛出异常。

images 注意Closeableclose()方法的实现为幂等;对close()的后续调用对资源没有影响。相比之下,AutoCloseableclose()方法的实现并不要求是幂等的,但是建议将其设为幂等的。

断言

写源代码不是一件容易的事情。太多时候,bug(缺陷)被引入到代码中。如果在编译源代码之前没有发现 bug,那么它就会进入运行时代码,而运行时代码很可能会意外失败。此时,很难确定失败的原因。

开发人员经常对应用的正确性做出假设,一些开发人员认为,在注释位置指定注释来陈述他们认为什么是真的就足以确定正确性。然而,注释对于防止错误是没有用的,因为编译器会忽略它们。

许多语言通过提供一种称为断言的语言特性来解决这个问题,这种语言特性允许开发人员编写关于应用正确性的假设。当应用运行时,如果断言失败,应用将终止,并显示一条消息,帮助开发人员诊断失败的原因。

本节向您介绍 Java 的断言语言特性。在定义了这个术语,向您展示了如何声明断言,并提供了示例之后,本节将着眼于使用和避免断言。最后,您将学习如何通过javac编译器工具的命令行参数有选择地启用和禁用断言。

声明断言

断言是一个让你通过布尔表达式表达程序正确性假设的语句。如果该表达式的计算结果为 true,则继续执行 ext 语句。否则,将引发一个标识失败原因的错误。

断言语句有两种形式,每种形式都以保留字assert开头:

assert *expression1* ;
assert *expression1* : *expression2* ;

在这两种形式的语句中, expression1 都是布尔表达式。在第二种形式中, expression2 是任何返回值的表达式。它不能调用返回类型为void的方法。

expression1 评估为 false 时,该语句实例化AssertionError类。第一个语句表单调用这个类的 noargument 构造函数,它没有将标识失败细节的消息与AssertionError实例相关联。

第二种形式调用一个类型与 expression2 的值类型相匹配的AssertionError构造函数。该值被传递给构造函数,其字符串表示形式被用作错误的详细信息。

当抛出错误时,源文件的名称和抛出错误的行号作为抛出错误的堆栈跟踪的一部分输出到控制台。在许多情况下,这些信息足以确定导致失败的原因,应该使用断言语句的第一种形式。

清单 3-31 展示了断言语句的第一种形式。

清单 3-31。抛出一个没有详细信息的断言错误

class AssertionDemo
{
   public static void main(String[] args)
   {
      int x = 1;
      assert x == 0;
   }
}

当启用断言时(我将在后面讨论这个任务),运行前面的应用会产生以下输出:

Exception in thread "main" java.lang.AssertionError
        at AssertionDemo.main(AssertionDemo.java:6)

在其他情况下,需要更多的信息来帮助诊断失败的原因。例如,假设 expression1 比较变量xy,当x的值超过y的值时抛出错误。因为这种情况总是会发生,所以您可能会使用第二种语句形式来输出这些值,以便可以诊断问题。

清单 3-32 展示了断言语句的第二种形式。

清单 3-32。抛出一个带有详细信息的断言错误

class AssertionDemo
{
   public static void main(String[] args)
   {
      int x = 1;
      assert x == 0: x;
   }
}

同样,假设断言是启用的。运行前面的应用会产生以下输出:

Exception in thread "main" java.lang.AssertionError: 1
        at AssertionDemo.main(AssertionDemo.java:6)

x中的值被附加到第一个输出行的末尾,这有点神秘。为了使这个输出更有意义,您可能希望指定一个表达式,其中也包括变量的名称:例如:assert x == 0: "x = "+x;

使用断言

在很多情况下都应该使用断言。这些情况被组织成内部不变量、控制流不变量和契约设计类别。不变量是不会改变的东西。

内部不变量

一个内部不变量是面向表达式的行为,不期望改变。例如,清单 3-33 通过链式 if-else 语句引入了一个内部不变量,它根据水的温度输出水的状态。

清单 3-33。发现内部不变量可以变化

class IIDemo {    public static void main(String[] args)    {       double temperature = 50.0; // Celsius       if (temperature **< 0.0**)          System.out.println("water has solidified");       else       if (temperature >= 100.0)          System.out.println("water is boiling into a gas");       else       {          // temperature > 0.0 and temperature < 100.0          assert(temperature > 0.0 && temperature < 100.0): temperature;          System.out.println("water is remaining in its liquid state");       }    } }

开发人员可能只指定一个注释来陈述一个假设,即什么表达式导致最终的else到达。因为注释可能不足以检测潜在的< 0.0表达式错误,所以断言语句是必要的。

内部不变量的另一个例子与没有默认情况的 switch 语句有关。因为开发人员相信所有的路径都被覆盖了,所以避免了默认的情况。然而,这并不总是正确的,如清单 3-34 所示。

清单 3-34。另一个漏洞百出的内部不变量

class IIDemo
{
   final static int NORTH = 0;
   final static int SOUTH = 1;
   final static int EAST = 2;
   final static int WEST = 3;
   public static void main(String[] args)
   {
      int direction = (int) (Math.random()***5**);
      switch (direction)
      {
         case NORTH: System.out.println("travelling north"); break;
         case SOUTH: System.out.println("travelling south"); break;
         case EAST : System.out.println("travelling east"); break;
         case WEST : System.out.println("travelling west"); break;
         default   : assert false;
      }
   }
}

清单 3-34 假设 switch 测试的表达式将只计算四个整型常量中的一个。但是,(int) (Math.random()*5)也可以返回 4,导致默认情况下执行assert false;,总是抛出AssertionError。(您可能需要运行这个应用几次才能看到断言错误,但是首先您需要学习如何启用断言,这将在本章的后面讨论。)

images 提示当断言被禁用时,assert false;不会执行,bug 不会被发现。要一直检测这个 bug,用throw new AssertionError(direction);替换assert false;

控制流不变量

控制流不变量是不期望改变的控制流。例如,清单 3-34 使用一个断言来测试一个假设,即开关的默认情况不会执行。清单 3-35 ,修复了清单 3-34 的 bug,提供了另一个例子。

清单 3-35。一个漏洞百出的控制流不变量

class CFDemo
{
   final static int NORTH = 0;
   final static int SOUTH = 1;
   final static int EAST = 2;
   final static int WEST = 3;
   public static void main(String[] args)
   {
      int direction = (int)(Math.random()*4);
      switch (direction)
      {
         case NORTH: System.out.println("travelling North"); break;
         case SOUTH: System.out.println("travelling south"); break;
         case EAST : System.out.println("travelling east"); break;
         case WEST : System.out.println("travelling west");
         default   : assert false;
      }
   }
}

因为最初的错误已经被修复,所以应该会达到默认情况。然而,终止case WEST的 break 语句的省略导致执行到达默认情况。这个控制流不变量被打破了。(同样,您可能需要运行这个应用几次才能看到断言错误,但是首先您需要学习如何启用断言,这将在本章后面讨论。)

images 警告使用断言语句来检测应该执行的代码时要小心。如果根据詹姆斯·高斯林、比尔·乔伊、盖伊·斯蒂尔和吉拉德·布拉查(Addison-Wesley,2005;ISBN: 0321246780)(也可以在([java.sun.com/docs/books/jls/third_edition/html/j3TOC.html](http://java.sun.com/docs/books/jls/third_edition/html/j3TOC.html))找到),编译器会报错。例如,for(;;); assert false;导致编译器报告一个错误,因为无限 For 循环阻止断言语句执行。

合同设计

契约式设计(见[en.wikipedia.org/wiki/Design_by_contract](http://en.wikipedia.org/wiki/Design_by_contract))是一种基于前置条件、后置条件和不变量(内部、控制流和类)来设计软件的方法。断言语句支持非正式的契约式设计风格的开发。

前提条件

一个前提条件是当一个方法被调用时必须为真的东西。断言语句通常用于通过检查参数是否合法来满足助手方法的前提条件。清单 3-36 提供了一个例子。

清单 3-36。验证前提条件

class Lotto649 {    public static void main(String[] args)    {       // Lotto 649 requires that six unique umbers be chosen.       int[] selectedNumbers = new int[6];       // Assign a unique random umber from 1 to 49 (inclusive) to each slot       // in the selectedNumbers array.       for (int slot = 0; slot < selectedNumbers.length; slot++)       {            int um;            // Obtain a random umber from 1 to 49\. That umber becomes the            // selected umber if it has not previously been chosen.            try_again:            do            {                num = rnd(49)+1;                for (int i = 0; i < slot; i++)                     if (selectedNumbers[i] == um)                         continue try_again;                break;            }            while (true);            // Assign selected umber to appropriate slot.            selectedNumbers[slot] = um;       }       // Sort all selected umbers into ascending order and then print these       // umbers.       sort(selectedNumbers);       for (int i = 0; i < selectedNumbers.length; i++)            System.out.print(selectedNumbers[i]+" ");    }    static int rnd(int limit)    {       // This method returns a random umber (actually, a pseudorandom umber)       // ranging from 0 through limit-1 (inclusive).       assert limit > 1: "limit = "+limit;       return (int) (Math.random()*limit);    }    static void sort(int[] x)    {       // This method sorts the integers in the passed array into ascending       // order.       for (int pass = 0; pass < x.length-1; pass++)          for (int i = x.length-1; i > pass; i--)             if (x[i] < x[pass])             {                int temp = x[i];                x[i] = x[pass];                x[pass] = temp;             }    } }

清单 3-36 的应用模拟了 Lotto 6/49,这是加拿大的国家彩票游戏之一。rnd() helper 方法返回一个在 0 和limit -1 之间随机选择的整数。断言语句验证了前提条件,即limit的值必须等于或大于 2。

images 注意sort()助手方法通过实现一个叫做冒泡排序算法(完成某项任务的诀窍)将(排序)数组selectedNumbers的整数按升序排序。

冒泡排序的工作原理是对数组进行多次遍历。在每一遍中,各种比较和交换确保 ext 最小的元素值向数组的顶部“冒泡”,这将是索引 0 处的元素。

冒泡排序效率不高,但对于排序六元素数组来说绰绰有余。虽然我可以使用位于java.util包的Arrays类中的一个有效的sort()方法(例如,Arrays.sort(selectedNumbers);完成了与清单 3-36 的的sort(selectedNumbers);方法调用相同的目标,但是这样做更有效),但是我选择使用冒泡排序,因为我更喜欢等到第五章之后再进入Arrays类。

后置条件

一个后置条件是在一个方法成功完成后必须为真的东西。断言语句通常用于通过检查结果是否合法来满足助手方法的后置条件。清单 3-37 提供了一个例子。

清单 3-37。验证后置条件和前提条件

class MergeArrays {    public static void main(String[] args)    {       int[] x = { 1, 2, 3, 4, 5 };       int[] y = { 1, 2, 7, 9 };       int[] result = merge(x, y);       for (int i = 0; i < result.length; i++)          System.out.println(result[i]);    }    static int[] merge(int[] a, int[] b)    {       if (a == null)          throw new nullPointerException("a is null");       if (b == null)          throw new nullPointerException("b is null");       int[] result = new int[a.length+b.length];       // Precondition       assert result.length == a.length+b.length: "length mismatch";       for (int i = 0; i < a.length; i++)          result[i] = a[i];       for (int i = 0; i < b.length; i++)          result[a.length+i-1] = b[i];       // Postcondition       assert containsAll(result, a, b): "value missing from array";       return result;    }    static boolean containsAll(int[] result, int[] a, int[] b)    {       for (int i = 0; i < a.length; i++)          if (!contains(result, a[i]))             return false;       for (int i = 0; i < b.length; i++)          if (!contains(result, b[i]))             return false;       return true;    }    static boolean contains(int[] a, int val)    {       for (int i = 0; i < a.length; i++)          if (a[i] == val)             return true;       return false;    } }

清单 3-37 使用一个断言语句来验证一个后置条件,即被合并的两个数组中的所有值都出现在合并后的数组中。然而,后置条件并不满足,因为这个清单包含一个 bug。

清单 3-37 也显示了前置条件和后置条件一起使用。唯一的前提条件验证合并后的数组长度等于在合并逻辑之前被合并的数组的长度。

类不变量

一个类不变量是一种内部不变量,它在任何时候都适用于一个类的每个实例,除了当一个实例从一个一致状态转换到另一个一致状态的时候。

例如,假设一个类的实例包含数组,数组的值按升序排序。您可能希望在类中包含一个isSorted()方法,如果数组仍然是按排序的,则该方法返回 true,并验证修改数组的每个构造函数和方法在退出前都指定了assert isSorted();,以满足当构造函数/方法存在时数组仍然是按排序的假设。

避免断言

尽管有很多情况下应该使用断言,但也有一些情况下应该避免使用断言。例如,您不应该使用断言来检查传递给公共方法的参数,原因如下:

  • 检查公共方法的参数是该方法及其调用方之间存在的契约的一部分。如果您使用断言来检查这些参数,并且如果断言被禁用,那么就违反了该契约,因为参数将不会被检查。
  • 断言还防止引发适当的异常。例如,当一个非法参数被传递给一个公共方法时,抛出IllegalArgumentExceptionNullPointerException是很常见的。然而,AssertionError反而被抛出。

您还应该避免使用断言来执行应用正常运行所需的工作。这项工作通常是作为断言的布尔表达式的副作用来执行的。当断言被禁用时,工作不会被执行。

例如,假设您有一个Employee对象的列表和一些空引用也存储在这个列表中,并且您想要移除所有的空引用。通过下面的断言语句删除这些引用是不正确的:

assert employees.removeAll(null);

尽管断言语句不会抛出AssertionError,因为在employees列表中至少有一个空引用,但是当断言被禁用时,依赖于该语句执行的应用将会失败。

与其依赖前面的代码来移除空引用,不如使用类似下面的代码:

boolean allNullsRemoved = employees.removeAll(null);
assert allNullsRemoved;

这一次,无论断言是启用还是禁用,所有的空引用都将被删除,并且您仍然可以指定一个断言来验证空引用是否被删除。

启用和禁用断言

编译器在类文件中记录断言。但是,断言在运行时被禁用,因为它们会影响性能。断言可能会调用一个需要一段时间才能完成的方法,这会影响正在运行的应用的性能。

在测试关于类行为的假设之前,必须启用类文件的断言。在运行java应用启动工具时,通过指定-enableassertions-ea命令行选项来完成这项任务。

-enableassertions-ea命令行选项允许您基于以下参数之一启用各种粒度的断言(除了 oargument 场景,您必须使用冒号将选项与其参数分隔开):

  • 无参数:断言在除系统类之外的所有类中都启用。
  • PackageName... :通过指定包名后跟...,断言在指定的包及其子包中被启用。
  • ... :断言在未命名的包中启用,这个包恰好是当前的目录。
  • ClassName :通过指定类名,在命名类中启用断言。

例如,当通过java -ea MergeArrays运行MergeArrays应用时,您可以启用除系统断言之外的所有断言。此外,您可以通过指定java -ea:logging TestLogger来启用本章的logging包中的任何断言。

通过指定-disableassertions-da命令行选项,可以禁用断言,也可以在不同的粒度上禁用断言。这些选项采用与-enableassertions-ea相同的参数。

例如,java -ea -da:*loneclass* *mainclass*启用除了 loneclass 中的断言之外的所有断言。( loneclassmainclass 是您指定的实际类的占位符。)

前面的选项适用于所有的类装入器(在附录 C 中讨论)。除了不带参数时,它们也适用于系统类。这个异常简化了除系统类之外的所有类中断言语句的启用,这通常是所希望的。

要启用系统断言,请指定-enablesystemassertions-esa;例如,java -esa -ea:logging TestLogger。指定-disablesystemassertions-dsa来禁用系统断言。

注释

在开发 Java 应用时,您可能想要注释各种应用元素,或者将元数据(描述其他数据的数据)与它们相关联。例如,您可能想要标识未完全实现的方法,以便不会忘记实现它们。Java 的注释语言特性让您可以完成这项任务。

本节向您介绍注释。在定义了这个术语并给出了三种编译器支持的注释作为例子之后,本节将向您展示如何声明您自己的注释类型并使用这些类型来注释源代码。最后,您会发现如何处理自己的注释来完成有用的任务。

images 注意 Java 一直支持特设注释机制。例如,java.lang.Cloneable接口标识了其实例可以通过Objectclone()方法浅克隆的类,transient保留字标记了序列化期间将被忽略的字段(在第八章中讨论过),以及@deprecated javadoc标记了不再受支持的方法。Java 6 通过引入注释语言特性,正式确定了对注释的需求。

发现注释

一个注释是一个注释类型的实例,它将元数据与应用元素相关联。它在源代码中是通过在类型名前面加上@符号来表达的。例如,@Readonly是一个注释,Readonly是它的类型。

images 注意可以使用注释将元数据与构造函数、字段、局部变量、方法、包、参数、类型(注释、类、枚举、接口)关联起来。

编译器支持OverrideDeprecatedSuppressWarningsSafeVarargs注释类型。这些类型位于java.lang包中。

注释对于表达子类方法覆盖超类中的方法是有用的,而不是重载那个方法。下面的示例显示了此批注用于作为重写方法的前缀:

@Override
public void draw(int color)
{
   // drawing code
}

@Deprecated注释对于指示标记的应用元素是弃用的(淘汰的)并且不应该再被使用是有用的。当不推荐使用的代码访问不推荐使用的应用元素时,编译器会向您发出警告。

相比之下,@deprecated javadoc标签和相关文本警告您不要使用不推荐使用的项目,并告诉您使用什么来代替。下面的例子说明了@Deprecated@deprecated可以一起使用:

/**
 * Allocates a <code>Date</code> object and initializes it so that
 * it represents midnight, local time, at the beginning of the day
 * specified by the <code>year</code>, <code>month</code>, and
 * <code>date</code> arguments.
 *
 * @param   year    the year minus 1900.
 * @param   month   the month between 0-11.
 * @param   date    the day of the month between 1-31.
 * @see     java.util.Calendar
 * @deprecated As of JDK version 1.1,
 * replaced by <code>Calendar.set(year + 1900, month, date)</code>
 * or <code>GregorianCalendar(year + 1900, month, date)</code>.
 */
@Deprecated
public Date(int year, int month, int date)
{
   this(year, month, date, 0, 0, 0);
}

这个例子摘录了 Java 的Date类中的一个构造函数(位于java.util包中)。它的 Javadoc 注释揭示了Date(int year, int month, int date)已经被弃用,取而代之的是在Calendar类(也位于java.util包中)中使用set()方法。(我在附录 c 中探索了DateCalendar。)

当编译单元(通常是类或接口)引用不推荐使用的类、方法或字段时,编译器会取消警告。这个特性允许你修改遗留的 API 而不产生反对警告,在清单 3-38 中有演示。

清单 3-38。在同一个类声明中引用一个不推荐使用的字段

class Employee
{
   /**
    * Employee's name
    * @deprecated new version uses firstName and lastName fields.
    */
   @Deprecated
   String name;
   String firstName;
   String lastName;
   public static void main(String[] args)
   {
      Employee emp = new Employee();
      emp.name = "John Doe";
   }
}

清单 3-38 声明了一个带有name字段的Employee类,该字段已被弃用。虽然Employeemain()方法引用了name,但是编译器会抑制一个弃用警告,因为弃用和引用发生在同一个类中。

假设您通过引入一个新的UseEmployee类并将Employeemain()方法移动到这个类来重构这个清单。清单 3-39 展示了最终的类结构。

清单 3-39。从另一个类声明中引用不推荐使用的字段

class Employee {    /**     * Employee's name     * @deprecated new version uses firstName and lastName fields.     */    @Deprecated    String name;    String firstName;    String lastName; } class UseEmployee {    public static void main(String[] args)    {       Employee emp = new Employee();       emp.name = "John Doe";    } }

如果您试图通过javac编译器工具编译该源代码,您会发现以下消息:

Note: Employee.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for details.

您需要将-Xlint:deprecation指定为javac的命令行参数之一(如在javac -Xlint:deprecation Employee.java中),以发现不推荐使用的项目和引用该项目的代码:

Employee.java:17: warning: [deprecation] name in Employee has been deprecated
      emp.name = "John Doe";
         ^
1 warning

@SuppressWarnings注释对于通过"deprecation""unchecked"参数抑制反对或未检查的警告很有用。(当混合使用泛型和前泛型遗留代码的代码时,会出现未检查的警告。我将在本章后面讨论泛型和未检查的警告。)

例如,当UseEmployee类的main()方法中的代码访问Employee类的name字段时,清单 3-40 使用带有"deprecation"参数的@SuppressWarnings来抑制编译器的反对警告。

清单 3-40。抑制先前的弃用警告

class Employee
{
   /**
    * Employee's name
    * @deprecated new version uses firstName and lastName fields.
    */
   @Deprecated
   String name;
   String firstName;
   String lastName;
}
class UseEmployee
{
   @SuppressWarnings("deprecation")
   public static void main(String[] args)
   {
      Employee emp = new Employee();
      emp.name = "John Doe";
   }
}

images 注意从风格上来说,你应该总是在嵌套最深的元素上指定@SuppressWarnings,在那里它是有效的。例如,如果您想要取消特定方法中的警告,您应该注释该方法而不是它的类。

最后,@SafeVarargs注释对于断言被注释的方法或构造函数的主体不会对其可变的参数数量执行潜在的不安全操作是很有用的。当我在本章后面介绍泛型的时候,我会对这个注释说得更多。

声明注释类型和注释源代码

在注释源代码之前,您需要可以实例化的注释类型。Java 提供了许多注释类型以及OverrideDeprecatedSuppressWarningsSafeVarargs。Java 也允许你声明你自己的类型。

通过指定符号@,紧接着是保留字interface,然后是类型名,最后是主体,来声明一个注释类型。例如,清单 3-41 使用@interface来声明一个名为Stub的注释类型。

清单 3-41。声明Stub注释类型

public @interface Stub
{
}

除了名称之外不提供任何数据的注释类型的实例——它们的主体是空的——被称为标记注释,因为它们出于某种目的标记应用元素。如清单 3-42 所示,@Stub用于标记空方法(存根)。

清单 3-42。注释一个被剔除的方法

public class Deck // Describes a deck of cards.
{
   @Stub
   public void shuffle()
   {
      // This method is empty and will presumably be filled in with appropriate
      // code at some later date.
   }
}

清单 3-42 的Deck类声明了一个空的shuffle()方法。这个事实是通过实例化Stub并在shuffle()的方法头前加上结果@Stub注释来表示的。

images 注意虽然标记接口(在第二章中介绍)看起来已经被标记注释所取代,但事实并非如此,因为标记接口比标记注释更有优势。一个优点是,标记接口指定了由标记类实现的类型,这让您可以在编译时捕捉问题。例如,如果一个类没有实现Cloneable接口,它的实例就不能通过Objectclone()方法被简单地克隆。如果Cloneable已经被实现为一个标记注释,这个问题直到运行时才会被发现。

虽然标记注释很有用(@Override@Deprecated就是很好的例子),但是您通常会希望增强注释类型,以便可以通过它的实例存储元数据。您可以通过向类型中添加元素来完成此任务。

一个元素是一个出现在注释类型主体中的方法头。它不能有参数或 throws 子句,其返回类型必须是基元类型(如int)、StringClass、枚举、批注类型或前面类型的数组。但是,它可以有默认值。

清单 3-43 给Stub增加了三个元素。

清单 3-43。添加三个元素到Stub注释类型

public @interface Stub
{
   int id(); // A semicolon must terminate an element declaration.
   String dueDate();
   String developer() default "unassigned";
}

id()元素指定了一个标识存根的 32 位整数。元素指定了一个基于 ?? 的日期,该日期标识了方法存根何时被实现。最后,developer()指定了负责编码方法存根的开发人员的基于String的名字。

id()dueDate()不同,developer()是用default值、"unassigned"来声明的。当您实例化Stub并且不为该实例中的developer()赋值时,如清单 3-44 中的所示,该默认值被赋给developer()

清单 3-44。初始化Stub实例的元素

public class Deck
{
   @Stub
   (
      id = 1,
      dueDate = "12/21/2012"
   )
   public void shuffle()
   {
   }
}

清单 3-44 揭示了一个@Stub注释,它将其id()元素初始化为1,将其dueDate()元素初始化为"12/21/2012"。每个元素名称没有尾随的(),两个元素初始化器的逗号分隔列表出现在()之间。

假设您决定用单个String value()元素替换Stubid()dueDate()developer()元素,该元素的字符串指定了逗号分隔的 ID、到期日和开发人员姓名值。清单 3-45 展示了两种初始化value的方法。

清单 3-45。初始化每个Stub实例的value()元素

public class Deck
{
   @Stub(value = "1,12/21/2012,unassigned")
   public void shuffle()
   {
   }
   @Stub("2,12/21/2012,unassigned")
   public Card[] deal(int cards)
   {
      return null;
   }
}

清单 3-45 揭示了对value()元素的特殊处理。当它是注释类型的唯一元素时,可以从初始化器中省略value()的名字和=。我用这个事实来指定清单 3-40 中的@SuppressWarnings("deprecation")

在注释类型声明中使用元注释

每个OverrideDeprecatedSuppressWarnings注释类型本身都用元注释进行注释(注释注释类型的注释)。例如,清单 3-46 展示了SuppressWarnings注释类型是用两个元注释来注释的。

清单 3-46。带注释的SuppressWarnings类型声明

@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE})
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings

位于java.lang.annotation包中的Target注释类型标识了注释类型所适用的应用元素的种类。@Target表示@SuppressWarnings注释可以用来注释类型、字段、方法、参数、构造函数和局部变量。

每个TYPEFIELDMETHODPARAMETERCONSTRUCTORLOCAL_VARIABLE都是ElementType enum 的成员,该 enum 也位于java.lang.annotation包中。

分配给Targetvalue()元素的逗号分隔的值列表周围的{}字符表示一个数组——value()的返回类型是String[]。尽管这些括号是必要的(除非数组由一个元素组成),但是在初始化@Target时可以省略value=,因为Target只声明了一个value()元素。

位于java.lang.annotation包中的Retention注释类型标识了注释类型的注释的保留期(也称为生存期)。@Retention表示@SuppressWarnings注释的生命周期仅限于源代码——它们在编译后不存在。

SOURCERetentionPolicy enum 的成员之一(位于java.lang.annotation包中)。其他成员是CLASSRUNTIME。这三个成员指定了以下保留策略:

  • CLASS:编译器在类文件中记录注释,但是 JVM 不保留它们(为了节省内存空间)。这是默认策略。
  • RUNTIME:编译器在类文件中记录注释,JVM 保留它们,以便在运行时可以通过反射 API(在第四章中讨论)读取它们。
  • SOURCE:编译器在使用注释后将其丢弃。

在清单 3-41 和清单 3-43 中显示的Stub注释类型有两个问题。首先,缺少一个@Target元注释意味着您可以注释任何应用元素@Stub。然而,这种注释只有在应用于方法和构造函数时才有意义。查看清单 3-47 。

清单 3-47。标注不需要的应用元素

@Stub("1,12/21/2012,unassigned")
public class Deck
{
   @Stub("2,12/21/2012,unassigned")
   private Card[] cardsRemaining = new Card[52];
   @Stub("3,12/21/2012,unassigned")
   public Deck()
   {
   }
   @Stub("4,12/21/2012,unassigned")
   public void shuffle()
   {
   }
   @Stub("5,12/21/2012,unassigned")
   public Card[] deal(@Stub("5,12/21/2012,unassigned") int cards)
   {
      return null;
   }
}

清单 3-47 使用@Stub来注释Deck类、cardsRemaining字段和ncards参数以及注释构造函数和两个方法。前三个应用元素不适合注释,因为它们不是存根。

您可以通过在Stub注释类型声明前面加上@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})来解决这个问题,这样Stub只适用于方法和构造函数。这样做之后,当您试图编译清单 3-47 时,javac编译器工具将输出以下错误信息:

Deck.java:1: error: annotation type not applicable to this kind of declaration
@Stub("1,12/21/2012,unassigned")
^
Deck.java:4: error: annotation type not applicable to this kind of declaration
   @Stub("2,12/21/2012,unassigned")
   ^
Deck.java:15: error: annotation type not applicable to this kind of declaration
   public Card[] deal(@Stub("5,12/21/2012,unassigned") int cards)
                      ^
3 errors

第二个问题是默认的CLASS保留策略使得在运行时无法处理@Stub注释。您可以通过在Stub类型声明前面加上@Retention(RetentionPolicy.RUNTIME)来解决这个问题。

清单 3-48 展示了带有期望的@Target@Retention元注释的Stub注释类型。

清单 3-48。一个改版的Stub注解式

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD, ElementType.CONSTRUCTOR})
@Retention(RetentionPolicy.RUNTIME)
public @interface Stub
{
   String value();
}

images 注意 Java 还在java.lang.annotation包中提供了DocumentedInherited元注释类型。@Documented-带注释的注释类型的实例将由javadoc和类似的工具记录,而@Inherited-带注释的注释类型的实例将被自动继承。根据Inherited的 Java 文档,如果“用户查询一个类声明上的注释类型,而该类声明没有该类型的注释,那么该类的超类将自动查询该注释类型。这个过程将一直重复,直到找到这个类型的注释,或者到达类层次结构的顶端(Object)。如果没有超类具有这种类型的注释,那么查询将指示所讨论的类没有这样的注释。”

加工标注

声明一个注释类型并使用该类型注释源代码是不够的。除非您对这些注释做一些特殊的处理,否则它们将保持休眠状态。完成特定任务的一种方法是编写处理注释的应用。清单 3-49 的StubFinder应用就是这么做的。

清单 3-49。StubFinder申请

`import java.lang.reflect.Method;

class StubFinder
{
   public static void main(String[] args) throws Exception
   {       if (args.length != 1)
      {
         System.err.println("usage: java StubFinder classfile");
         return;
      }
      Method[] methods = Class.forName(args[0]).getMethods();
      for (int i = 0; i < methods.length; i++)
         if (methods[i].isAnnotationPresent(Stub.class))
         {
            Stub stub = methods[i].getAnnotation(Stub.class);
            String[] components = stub.value().split(",");
            System.out.println("Stub ID = "+components[0]);
            System.out.println("Stub Date = "+components[1]);
            System.out.println("Stub Developer = "+components[2]);
            System.out.println();
         }
   }
}`

StubFinder加载一个类文件,其名称被指定为命令行参数,并输出与每个public方法头之前的每个@Stub注释相关联的元数据。这些注释是清单 3-48 的Stub注释类型的实例。

StubFinder next 使用一个名为Class(在java.lang包中)的特殊类及其forName()类方法来加载一个类文件。Class还提供了一个getMethods()方法,该方法返回一个Method对象的数组,这些对象描述了加载的类的公共方法。

对于每一次循环迭代,调用一个Method对象的isAnnotationPresent()方法,以确定该方法是否用由Stub类(称为Stub.class)描述的注释进行了注释。

如果isAnnotationPresent()返回 true,调用MethodgetAnnotation()方法返回注释Stub实例。调用该实例的value()方法来检索存储在注释中的字符串。

接下来,Stringsplit()方法被调用来将字符串的逗号分隔的 ID、日期和开发者值列表分割成一个String对象数组。然后每个对象连同描述性文本一起输出。

ClassforName()方法能够抛出各种异常,这些异常必须作为方法头的一部分进行处理或显式声明。为了简单起见,我选择在main()方法的头部添加一个throws Exception子句。

images 小心throws Exception有两个问题。首先,处理异常并给出合适的错误消息比通过抛出main()来“推卸责任”要好。其次,Exception是通用的——它隐藏了被抛出的异常类型的名称。然而,在一次性工具中指定throws Exception很方便。

如果你不明白ClassforName()getMethods()MethodisAnnotationPresent().classgetAnnotation()split(),请不要担心。您将在第四章中了解这些项目。

编译完StubFinder ( javac StubFinder.java)、Stub ( javac Stub.java)和清单 3-45 的Deck类(javac Deck.java)后,运行StubFinder,将Deck作为其单一命令行参数(java StubFinder Deck)。您将看到以下输出:

Stub ID = 2
Stub Date = 12/21/2012
Stub Developer = unassigned

Stub ID = 1
Stub Date = 12/21/2012
Stub Developer = unassigned

如果您期望输出反映出Deck.java@Stub注释的出现顺序,您可能会对输出的无序顺序感到惊讶。这种无序是由getMethods()造成的。根据这个方法的 Java 文档,“返回的数组中的元素没有排序,也没有任何特定的顺序。”

images Java 5 引入了一个用于处理注释的apt工具。从 Java 6 开始,这个工具的功能已经集成到编译器中—apt正在被淘汰。我的“Java 工具注释处理器”教程([tutortutor.ca/cgi-bin/makepage.cgi?/tutorials/ct/jtap](http://tutortutor.ca/cgi-bin/makepage.cgi?/tutorials/ct/jtap))提供了使用 Java 编译器处理注释的教程。

仿制药

Java 5 引入了泛型,用于声明和使用与类型无关的类和接口的语言特性。当使用 Java 的集合框架时(我在第五章的中介绍过),这些特性帮助你避免抛出java.lang.ClassCastException类的实例。

images 注意虽然泛型的主要用途是集合框架,但是 Java 的类库也包含了与这个框架无关的泛型化(为利用泛型而改造的)类:java.lang.Classjava.lang.ThreadLocaljava.lang.ref.WeakReference就是三个例子。

本节向您介绍泛型。首先学习泛型如何在集合框架类的上下文中促进类型安全,然后在泛型类型和泛型方法的上下文中探索泛型。在学习了数组上下文中的泛型之后,您将学习如何使用SafeVarargs注释类型。

集合和类型安全的需要

Java 的集合框架使得在各种对象容器(称为集合)中存储对象并在以后检索这些对象成为可能。例如,您可以将对象存储在列表、集合或映射中。然后,您可以检索单个对象,或者循环访问集合并检索所有对象。

在 Java 5 改革集合框架以利用泛型之前,没有办法阻止集合包含混合类型的对象。在将对象添加到集合之前,编译器没有检查对象的类型以确定它是否合适,这种静态类型检查的缺乏导致了ClassCastException s。

清单 3-50 展示了生成一个ClassCastException是多么容易。

清单 3-50。缺乏类型安全导致运行时ClassCastException

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

class Employee
{
   private String name;
   Employee(String name)
   {
      this.name = name;
   }
   String getName()
   {
      return name;
   }
}
class TypeSafety
{
   public static void main(String[] args)
   {
      List employees = new ArrayList();
      employees.add(new Employee("John Doe"));
      employees.add(new Employee("Jane Smith"));
      employees.add("Jack Frost");
      Iterator iter = employees.iterator();
      while (iter.hasNext())
      {
         Employee emp = (Employee) iter.next();
         System.out.println(emp.getName());
      }
   }
}

清单 3-50 的main()方法首先实例化java.util.ArrayList,然后使用这个列表集合对象的引用将一对Employee对象添加到列表中。然后它添加了一个String对象,这违反了隐含契约,即ArrayList应该只存储Employee对象。

继续,main()获得一个java.util.Iterator实例,用于迭代Employee的列表。只要IteratorhasNext()方法返回 true,就会调用它的next()方法返回一个存储在数组列表中的对象。

next()返回的Object必须向下转换为Employee,这样就可以调用Employee对象的getName()方法来返回员工的姓名。该方法返回的字符串通过System.out.println()输出到标准输出设备。

(Employee) cast 检查由next()返回的每个对象的类型,以确保它是Employee。虽然前两个对象是这样,但第三个对象不是这样。试图将"Jack Frost"转换为Employee会导致ClassCastException

因为假设列表是同质的,所以出现了ClassCastException。换句话说,列表只存储单一类型或一系列相关类型的对象。实际上,该列表是异构的,因为它可以存储任何Object

清单 3-51 的基于泛型的同质列表避免了ClassCastException

清单 3-51。缺乏类型安全导致编译器错误

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

class Employee
{
   private String name;
   Employee(String name)
   {
      this.name = name;
   }
   String getName()
   {
      return name;
   }
}
class TypeSafety
{
   public static void main(String[] args)
   {
      List<Employee> employees = new ArrayList<Employee>();
      employees.add(new Employee("John Doe"));
      employees.add(new Employee("Jane Smith"));
      employees.add("Jack Frost");
      Iterator<Employee> iter = employees.iterator();
      while (iter.hasNext())
      {
         Employee emp = iter.next();
         System.out.println(emp.getName());
      }
   }
}

清单 3-51 的重构的main()方法说明了泛型的核心特征,即参数化类型(一个类或接口名,后跟一个尖括号分隔的类型列表,标识在该上下文中什么类型的对象是合法的)。

例如,List<Employee>表示只有Employee对象可以存储在List中。如图所示,<Employee>指定可以用ArrayList重复,如在Arraylist<Employee>中,它是存储Employee的集合实现。因为编译器可以从上下文中找出这个类型参数,所以您可以从ArrayList<>字符之间省略多余的Employee类型名称,从而得到List<Employee> employees = new ArrayList<>();

images 由于其外观,很多开发者将<>字符序列称为钻石运算符。我不认为<>是一个真正的操作符,这也是为什么我没有把它包含在表 1-3 的 Java 操作符列表中。

另外,Iterator<Employee>——在这个上下文中不能使用菱形操作符——表示iterator()返回一个Iterator,它的next()方法只返回Employee对象。没有必要将iter.next()的返回值强制转换为Employee,因为编译器会代表您插入强制转换。

如果您试图编译这个清单,编译器会在遇到employees.add("Jack Frost");时报告一个错误。错误消息会告诉你编译器在java.util.List<Employee>接口中找不到add(java.lang.String)方法。

与声明了一个add(Object)方法的前泛型List接口不同,泛型化的List接口的add()方法参数反映了接口的参数化类型名。例如,List<Employee>寓意add(Employee)

清单 3-50 揭示了导致ClassCastException ( employees.add("Jack Frost");)的不安全代码和触发异常的代码((Employee) iter.next())非常接近。但是,在较大的应用中,它们之间的距离往往更远。

您不必在寻找最终导致ClassCastException的不安全代码时处理愤怒的客户,您可以依靠编译器通过在编译期间检测到代码时报告错误来为您节省这种挫折和精力。在编译时检测类型安全违规是使用泛型的好处。

通用类型

一个泛型类型是一个类或接口,它通过声明一个形式类型参数列表(尖括号之间的一个逗号分隔的类型参数名称列表)来引入一族参数化类型。该语法表示如下:

class *identifier*<*formal_type_parameter_list*> {}
interface *identifier*<*formal_type_parameter_list*> {}

例如,List<E>是一个泛型类型,其中List是一个接口,类型参数E标识列表的元素类型。类似地,Map<K, V>是一个泛型类型,其中Map是一个接口,类型参数KV标识映射的键和值类型。

images 注意声明泛型类型时,习惯上指定单个大写字母作为类型参数名。此外,这些名称应该有意义。例如,E表示元素,T表示类型,K表示键,V表示值。如果可能,您应该避免选择在使用它的地方没有意义的类型参数名称。例如,List<E>是元素列表的意思,但是List<S>是什么意思呢?

参数化类型实例化泛型类型。每个参数化类型都用类型名替换泛型类型的类型参数。例如,List<Employee>(EmployeeList)和List<String>(StringList)就是基于List<E>的参数化类型的例子。类似地,Map<String, Employee>是基于Map<K, V>的参数化类型的一个例子。

替换类型参数的类型名称为实际类型参数。泛型支持五种实际类型参数:

  • 具体类型:将类或接口的名称传递给类型参数。例如,List<Employee> employees;指定列表元素是Employee实例。
  • 具体参数化类型:参数化类型的名称被传递给类型参数。例如,List<List<String>> nameLists;指定列表元素是字符串列表。
  • 数组类型:数组被传递给类型参数。例如,List<String[]> countries;指定列表元素是由String组成的数组,可能是城市名。
  • 类型参数:类型参数传递给类型参数。例如,给定的类声明class X<E> { List<E> queue; }X的类型参数E被传递给List的类型参数E
  • 通配符:将?传递给类型参数,表示未知的实际类型参数。例如,List<?> list;指定列表元素未知。你将在本章的后面学习通配符。

泛型类型还标识了一个原始类型,它是一个没有类型参数的泛型类型。比如List<Employee>的 raw 类型是List。原始类型是非泛型的,可以保存任何Object

images 注意 Java 允许原始类型与泛型混合,以支持在泛型出现之前编写的大量遗留代码。但是,每当编译器在源代码中遇到原始类型时,它都会输出一条警告消息。

声明和使用你自己的泛型类型

声明自己的泛型类型并不难。除了指定一个正式的类型参数列表之外,泛型类型还在它的整个实现过程中指定它的类型参数。例如,清单 3-52 声明了一个Queue<E>泛型类型。

清单 3-52。声明和使用Queue<E>泛型类型

class Queue<E> {    private E[] elements;    private int head, tail;    @SuppressWarnings("unchecked")    Queue(int size) {       if (size < 2)          throw new IllegalArgumentException(""+size);       elements = (E[]) new Object[size];       head = 0;       tail = 0;    }    void insert(E element) throws QueueFullException    {       if (isFull())          throw new QueueFullException();       elements[tail] = element;       tail = (tail+1)%elements.length;    }    E remove() throws QueueEmptyException    {       if (isEmpty())          throw new QueueEmptyException();       E element = elements[head];       head = (head+1)%elements.length;       return element;    }    boolean isEmpty()    {       return head == tail;    }    boolean isFull()    {       return (tail+1)%elements.length == head;    }    public static void main(String[] args)       throws QueueFullException, QueueEmptyException    {       Queue<String> queue = new Queue<>(6);       System.out.println("Empty: "+queue.isEmpty());       System.out.println("Full: "+queue.isFull());       System.out.println("Adding A");       queue.insert("A");       System.out.println("Adding B");       queue.insert("B");       System.out.println("Adding C");       queue.insert("C");       System.out.println("Adding D");       queue.insert("D");       System.out.println("Adding E");       queue.insert("E");       System.out.println("Empty: "+queue.isEmpty());       System.out.println("Full: "+queue.isFull());       System.out.println("Removing "+queue.remove());       System.out.println("Empty: "+queue.isEmpty());       System.out.println("Full: "+queue.isFull());       System.out.println("Adding F");       queue.insert("F");       while (!queue.isEmpty())          System.out.println("Removing "+queue.remove());       System.out.println("Empty: "+queue.isEmpty());       System.out.println("Full: "+queue.isFull());    } } class QueueEmptyException extends Exception { } class QueueFullException extends Exception { }

清单 3-52 声明了QueueQueueEmptyExceptionQueueFullException类。后两个类描述了从前一个类的方法中抛出的检查异常。

Queue实现了一个队列,一个按照先进先出顺序存储元素的数据结构。在尾部插入一个元件,在头部移除一个元件。当头等于尾时,队列为空,当尾比头少一个时,队列为满。因此,一个大小为 n 的队列最多可以存储 n -1 个元素。

注意Queue<E>E类型参数出现在整个源代码中。例如,E出现在elements数组声明中,表示数组的元素类型。E也被指定为insert()的参数类型和remove()的返回类型。

E也出现在elements = (E[]) new Object[size];中。(我将在后面解释为什么我指定这个表达式,而不是指定更紧凑的elements = new E[size];表达式。)

E[]强制转换导致编译器警告该强制转换未被检查。编译器担心从Object[]向下转换到E[]可能会导致违反类型安全,因为任何类型的对象都可以存储在Object[]中。

在这个例子中,编译器的担心是不合理的。on- E对象不可能出现在E[]数组中。因为警告在这个上下文中没有意义,所以通过在构造函数前面加上@SuppressWarnings("unchecked")来取消警告。

images 注意抑制未检查的警告时要小心。你必须先证明一个ClassCastException不可能发生,然后才能抑制警告。

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

Empty: true Full: false Adding A Adding B Adding C Adding D Adding E Empty: false Full: true Removing A Empty: false Full: false Adding F Removing B Removing C Removing D Removing E Removing F Empty: true Full: false

类型参数界限

List<E>E类型参数和Map<K, V>KV类型参数是无界类型参数的例子。您可以将任何实际类型参数传递给未绑定的类型参数。

有时有必要限制可以传递给类型参数的实际类型变量的种类。例如,你可能想声明一个类,它的实例只能存储抽象Shape类的子类的实例(比如CircleRectangle)。

为了限制实际类型参数,您可以指定一个上限,这是一个可以作为实际类型参数的类型上限的类型。上限是通过保留字extends后跟类型名来指定的。

例如,ShapesList<E extends Shape>Shape标识为上界。你可以指定ShapesList<Circle>ShapesList<Rectangle>,甚至ShapesList<Shape>,但不能指定ShapesList<String>,因为String不是Shape的子类。

通过使用&符号(&)分隔绑定名称,可以为类型参数分配多个上限,其中第一个绑定是类或接口,每个附加的上限是接口。考虑清单 3-53 中的。

清单 3-53。为类型参数分配多个上限

abstract class Shape { } class Circle extends Shape implements Comparable<Circle> {    private double x, y, radius;    Circle(double x, double y, double radius)    {       this.x = x;       this.y = y;       this.radius = radius;    }    @Override    public int compareTo(Circle circle)    {       if (radius < circle.radius)          return -1;       else       if (radius > circle.radius)          return 1;       else          return 0;    }    @Override    public String toString()    {       return "("+x+", "+y+", "+radius+")";    } } class SortedShapesList<S extends Shape&Comparable<S>> {    @SuppressWarnings("unchecked")    private S[] shapes = (S[]) new Shape[2];    private int index = 0;    void add(S shape)    {       shapes[index++] = shape;       if (index < 2)          return;       System.out.println("Before sort: "+this);       sort();       System.out.println("After sort: "+this);    }    private void sort()    {       if (index == 1)          return;       if (shapes[0].compareTo(shapes[1]) > 0)       {          S shape = (S) shapes[0];          shapes[0] = shapes[1];          shapes[1] = shape;       }    }    @Override    public String toString()    {       return shapes[0].toString()+" "+shapes[1].toString();    } } class SortedShapesListDemo {    public static void main(String[] args)    {       SortedShapesList<Circle> ssl = new SortedShapesList<>();       ssl.add(new Circle(100, 200, 300));       ssl.add(new Circle(10, 20, 30));    } }

清单 3-53 的Circle类扩展了Shape并实现了java.lang.Comparable接口,用于指定Circle对象的自然排序。接口的compareTo()方法通过返回值反映顺序来实现这种排序:

  • 如果当前对象应该在以某种方式传递给compareTo()的对象之前,则返回负值。
  • 如果当前对象和参数对象相同,则返回零值。
  • 如果当前对象应该在 argument 对象之后,则返回一个正值。

Circle的覆盖compareTo()方法根据半径比较两个Circle对象。该方法将半径较小的Circle实例排在半径较大的Circle实例之前。

SortedShapesList类指定<S extends Shape&Comparable<S>>作为它的参数列表。传递给S参数的实际类型参数必须是Shape的子类,并且它还必须实现Comparable接口。

images 注意包含类型参数的类型参数界限被称为递归类型界限。比如S extends Shape&Comparable<S>中的Comparable<S>就是递归类型绑定。递归类型界限很少见,通常与Comparable接口一起出现,用于指定类型的自然排序。

Circle满足两个标准:它继承了Shape并实现了Comparable。因此,当遇到main()方法的SortedShapesList<Circle> ssl = new SortedShapesList<>();语句时,编译器不会报告错误。

上限提供额外的静态类型检查,保证参数化类型遵守其界限。这种保证意味着可以安全地调用上限的方法。比如,sort()可以调用ComparablecompareTo()方法。

如果您运行这个应用,您会发现下面的输出,它显示了两个Circle对象按照半径的升序排序:

Before sort: (100.0, 200.0, 300.0) (10.0, 20.0, 30.0)
After sort: (10.0, 20.0, 30.0) (100.0, 200.0, 300.0)

images 注意类型参数不能有下限。Angelika Langer 在她的“Java 泛型常见问题”中解释了这种限制的基本原理(见[www.angelikalanger.com/GenericsFAQ/FAQSections/TypeParameters.html#FAQ107](http://www.angelikalanger.com/GenericsFAQ/FAQSections/TypeParameters.html#FAQ107))。

类型参数范围

类型参数的范围(可见性)是其泛型类型,除非被屏蔽(隐藏)。此范围包括类型参数是其成员的形式类型参数列表。比如SortedShapesList<S extends Shape&Comparable<S>>中的S的范围就是SortedShapesList和形参表的全部。

可以通过在嵌套类型的形式类型参数列表中声明同名的类型参数来屏蔽类型参数。例如,清单 3-54 屏蔽了一个封闭类的T类型参数。

清单 3-54。屏蔽一个类型变量

class EnclosingClass<T>
{
   static class EnclosedClass<T extends Comparable<T>>
   {
   }
}

EnclosingClassT类型参数被EnclosedClassT类型参数屏蔽,后者指定了一个上限,在这个上限上,只有那些实现了Comparable接口的类型才能被传递给EnclosedClass。从EnclosedClass内部引用T是指有界的T,而不是传递给EnclosingClass的无界的T

如果不需要屏蔽,最好为类型参数选择不同的名称。例如,您可以指定EnclosedClass<U extends Comparable<U>>。虽然U不像T那样是一个有意义的名字,但这种情况证明了这个选择的合理性。

对通配符的需求

假设你已经创建了一个StringList并想要输出这个列表。因为您可能会创建一个EmployeeList和其他类型的列表,所以您希望这个方法输出一个任意的ObjectList。您最终创建了清单 3-55 中的。

清单 3-55。试图输出一个ObjectList

import java.util.ArrayList;
import java.util.List;

class OutputList
{
   public static void main(String[] args)
   {
      List<String> ls = new ArrayList<>();
      ls.add("first");
      ls.add("second");
      ls.add("third");
      outputList(ls);
   }
   static void outputList(List<Object> list)
   {
      for (int i = 0; i < list.size(); i++)
         System.out.println(list.get(i));
   }
}

现在你已经完成了你的目标(或者你认为如此),你通过javac OutputList.java编译清单 3-55 。令您惊讶的是,您会收到以下错误消息:

OutputList.java:12: error: method outputList in class OutputList cannot be applied to given
types;
      outputList(ls);
      ^
  required: List<Object>
  found: List<String>
  reason: actual argument List<String> cannot be converted to List<Object> by method
invocation conversion
1 error

这个错误消息是由于没有意识到泛型类型的基本规则:对于给定类型 y 的子类型 x 的**,并且给定 G 作为原始类型声明,G不是 G < y >** 的子类型。

为了理解这个规则,你必须刷新你对子类型多态性的理解(见第二章)。基本上,一个子类型是一种特殊的超类型。例如,CircleShape的一种特殊类型,StringObject的一种特殊类型。这种多态行为也适用于具有相同类型参数的相关参数化类型(例如,List<Object>是一种特殊的java.util.Collection<Object>)。

但是,这种多态行为不适用于仅在一个类型参数是另一个类型参数的子类型方面不同的多个参数化类型。比如List<String>就不是List<Object>的专门种类。下面的示例揭示了为什么只有类型参数不同的参数化类型不是多态的:

List<String> ls = new ArrayList<>();
List<Object> lo = ls;
lo.add(new Employee());
String s = ls.get(0);

此示例不会编译,因为它违反了类型安全。如果它被编译,运行时会抛出一个ClassCastException,因为在最后一行会隐式转换为String

第一行实例化了String的一个List,第二行将其引用向上转换为Object的一个List。第三行向ObjectList添加一个新的Employee对象。第四行通过get()获得Employee对象,并试图将其赋给String引用变量的List。然而,ClassCastException被抛出是因为隐式转换为String——一个Employee不是一个String

images 注意虽然您不能将List<String>向上转换为List<Object>,但是您可以将List<String>向上转换为原始类型List,以便与遗留代码进行互操作。

前述错误信息显示StringList并不是ObjectList。要在不违反类型安全的情况下调用清单 3-55 的outputList()方法,只能传递一个List<Object>类型的参数,这限制了该方法的有用性。

然而,泛型提供了一个解决方案:通配符参数(?),它代表任何类型。通过将outputList()的参数类型从List<Object>改为List<?>,可以用StringListEmployeeList来调用outputList(),以此类推。

通用方法

假设您需要一个方法将任何类型对象的一个List复制到另一个List。尽管您可能会考虑编写一个void copyList(List<Object> src, List<Object> dest)方法,但是这个方法的用处有限,因为它只能复制元素类型为Object的列表。例如,你不能复制一部List<Employee>

如果您想要传递其元素为任意类型的源列表和目的列表(但是它们的元素类型一致),您需要指定通配符作为该类型的占位符。例如,您可以考虑编写下面的copyList()类方法,该方法接受任意类型对象的集合作为其参数:

static void copyList(List<?> src, List<?> dest)
{
   for (int i = 0; i < src.size(); i++)
      dest.add(src.get(i));
}

这个方法的参数列表是正确的,但是还有一个问题:编译器遇到dest.add(src.get(i));时输出如下错误信息:

CopyList.java:18: error: no suitable method found for add(Object)
         dest.add(src.get(i));
             ^
    method List.add(int,CAP#1) is not applicable
      (actual and formal argument lists differ in length)
    method List.add(CAP#1) is not applicable
      (actual argument Object cannot be converted to CAP#1 by method invocation conversion)
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
1 error

该错误消息假设copyList()是名为CopyList的类的一部分。尽管它看起来不可理解,但这个消息基本上意味着dest.add(src.get(i))方法调用违反了类型安全。因为?意味着任何类型的对象都可以作为列表的元素类型,所以目标列表的元素类型可能与源列表的元素类型不兼容。

例如,假设您创建一个StringList作为源列表,创建一个EmployeeList作为目的列表。试图将源列表的String元素添加到目标列表,这违反了类型安全。如果这个复制操作被允许,那么当试图获取目标列表的元素时会抛出一个ClassCastException实例。

您可以通过指定void copyList(List<String> src, List<String> dest)来避免这个问题,但是这个方法头限制您只能复制String对象的列表。或者,您可以限制通配符参数,如下所示:

static void copyList(List<? extends String> src,
                     List<? super String> dest)
{
   for (int i = 0; i < src.size(); i++)
      dest.add(src.get(i));
}

这个方法演示了通配符参数的一个特性:您可以提供一个上限或下限(与类型参数不同)来限制可以作为实际类型参数传递给泛型类型的类型。通过extends指定一个上界,在?之后是上界类型,通过super指定一个下界,在?之后是下界类型。

您将? extends String解释为任何实际类型参数(即String或子类)都可以被传递,而将? super String解释为任何实际类型参数(即String或超类)都可以被传递。因为String不能被子类化,这意味着你只能传递String的源列表和StringObject的目的列表。

将任意元素类型的列表复制到其他列表的问题可以通过使用泛型方法(具有类型一般化实现的类或实例方法)来解决。泛型方法在语法上表示如下:

<*formal_type_parameter_list*> *return_type* *identifier*(*parameter_list*)

formal_type_parameter_list 与指定泛型类型时相同:它由具有可选边界的类型参数组成。类型参数可以作为方法的 return_type 出现,类型参数可以出现在 parameter_list 中。编译器从调用方法的上下文中推断实际的类型参数。

您将在集合框架中发现许多泛型方法的例子。例如,它的java.util.Collections类提供了一个public static <T extends Object & Comparable<? super T>> T min(Collection<? extends T> coll)方法,用于根据提供的java.util.Comparator实例指定的顺序返回给定Collection中的最小元素。

通过在返回类型前加前缀<T>,并用T替换每个通配符,可以很容易地将copyList()转换成泛型方法。产生的方法头是<T> void copyList(List<T> src, List<T> dest),而清单 3-56 将其源代码作为应用的一部分,该应用将Circle的一个List复制到Circle的另一个List

清单 3-56。声明和使用copyList()泛型方法

`import java.util.ArrayList;
import java.util.List;

class Circle
{
   private double x, y, radius;
   Circle(double x, double y, double radius)
   {
      this.x = x;
      this.y = y;
      this.radius = radius;
   }
   @Override
   public String toString()
   {
      return "("+x+", "+y+", "+radius+")";
   }
}
class CopyList
{
   public static void main(String[] args)
   {
      List ls = new ArrayList();
      ls.add("A");
      ls.add("B");       ls.add("C");
      outputList(ls);
      List lsCopy = new ArrayList();
      copyList(ls, lsCopy);
      outputList(lsCopy);
      List lc = new ArrayList();
      lc.add(new Circle(10.0, 20.0, 30.0));
      lc.add(new Circle (5.0, 4.0, 16.0));
      outputList(lc);
      List lcCopy = new ArrayList();
      copyList(lc, lcCopy);
      outputList(lcCopy);
   }
   static void copyList(List src, List dest)
   {
      for (int i = 0; i < src.size(); i++)
         dest.add(src.get(i));
   }
   static void outputList(List<?> list)
   {
      for (int i = 0; i < list.size(); i++)
         System.out.println(list.get(i));
      System.out.println();
   }
}`

泛型方法的类型参数是从调用该方法的上下文中推断出来的。例如,编译器确定copyList(ls, lsCopy);String的一个List复制到String的另一个List。同样,它确定copyList(lc, lcCopy);Circle的一个List复制到Circle的另一个List

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

A
B
C

A
B
C

(10.0, 20.0, 30.0)
(5.0, 4.0, 16.0)

(10.0, 20.0, 30.0)
(5.0, 4.0, 16.0)

数组和泛型

在展示了清单 3-52 的Queue<E>泛型类型后,我提到我将解释为什么我指定了elements = (E[]) new Object[size];而不是更紧凑的elements = new E[size];表达式。由于 Java 的泛型实现,不可能指定包含类型参数(例如,new E[size]new List<E>[50])或实际类型参数(例如,new Queue<String>[15])的数组创建表达式。如果您试图这样做,编译器将报告一个generic array creation错误消息。

在我给出一个例子来说明为什么允许包含类型参数或实际类型实参的数组创建表达式是危险的之前,您需要理解数组上下文中的具体化和协方差,以及擦除,这是泛型实现的核心。

具体化将抽象表现为具体——例如,使内存地址可以被其他语言结构直接操作。Java 数组是具体化的,因为它们知道自己的元素类型(元素类型存储在内部),并且可以在运行时强制使用这些类型。试图在数组中存储无效元素会导致 JVM 抛出一个java.lang.ArrayStoreException类的实例。

清单 3-57 教你数组操作如何导致ArrayStoreException:

清单 3-57。一个ArrayStoreException如何产生

class Point
{
   int x, y;
}
class ColoredPoint extends Point
{
   int color;
}
class ReificationDemo
{
   public static void main(String[] args)
   {
      ColoredPoint[] cptArray = new ColoredPoint[1];
      Point[] ptArray = cptArray;
      ptArray[0] = new Point();
   }
}

清单 3-57 的main()方法首先实例化一个可以存储一个元素的ColoredPoint数组。与这种合法的赋值相反(类型是兼容的),指定ColoredPoint[] cptArray = new Point[1];是非法的(并且不会被编译),因为它会在运行时导致一个ClassCastException——数组知道这种赋值是非法的。

images 注意如果不明显,ColoredPoint[] cptArray = new Point[1];是非法的,因为Point实例比ColoredPoint实例(xycolor)成员少(只有xy)。试图从ColoredPoint数组中的条目访问Point实例不存在的color字段将导致内存冲突(因为没有内存分配给color),并最终导致 JVM 崩溃。

第二行(Point[] ptArray = cptArray;)是合法的,因为协方差(超类型引用数组是子类型引用数组的超类型)。在这种情况下,Point引用数组是ColoredPoint引用数组的超类型。onarray 的类比是子类型也是超类型。例如,Throwable实例是一种Object实例。

协方差被滥用是危险的。例如,第三行(ptArray[0] = new Point();)在运行时产生ArrayStoreException,因为Point实例不是ColoredPoint实例。如果没有这个异常,试图访问不存在的成员color会导致 JVM 崩溃。

与数组不同,泛型类型的类型参数没有具体化。它们在运行时是不可用的,因为它们在源代码编译后就被扔掉了。这种“丢弃类型参数”是删除的结果,这也涉及到当代码类型不正确时向适当的类型插入强制转换,并用类型参数的上限替换它们(如Object)。

images 编译器执行擦除,让泛型代码与遗留(非泛型)代码互操作。它将通用源代码转换成非通用运行时代码。擦除的一个后果是,除了无限制的通配符类型之外,不能对参数化类型使用instanceof操作符。例如,指定List<Employee> le = null; if (le instanceof ArrayList<Employee>) {}是非法的。相反,您必须将instanceof表达式改为le instanceof ArrayList<?>(无界通配符)或le instanceof ArrayList(原始类型,这是首选用法)。

假设您可以指定一个包含类型参数或实际类型参数的数组创建表达式。为什么这不好?为了回答这个问题,考虑下面的例子,这个例子应该生成一个ArrayStoreException而不是一个ClassCastException,但是没有这样做:

List<Employee>[] empListArray = new List<Employee>[1];
List<String> strList = new ArrayList<>();
strList.add("string");
Object[] objArray = empListArray;
objArray[0] = strList;
Employee e = empListArray[0].get(0);

让我们假设第一行是合法的,它创建了一个单元素数组,其中该元素存储了一个EmployeeList。第二行创建了一个StringList,第三行在这个列表中存储了一个String对象。

第四行将empListArray赋给objArray。这种赋值是合法的,因为数组是协变的,擦除将List<Employee>[]转换为List运行时类型,以及List子类型Object

因为擦除,JVM 遇到objArray[0] = strList;时不会抛出ArrayStoreException。毕竟,我们在运行时将一个List引用赋给了一个List[]数组。然而,如果泛型类型被具体化,这个异常就会被抛出,因为我们会将一个List<String>引用赋给一个List<Employee>[]数组。

但是,有一个问题。一个List<String>实例被存储在一个只能容纳List<Employee>实例的数组中。当编译器插入的 cast 操作符试图将empListArray[0].get(0)的返回值("string")转换为Employee时,cast 操作符抛出一个ClassCastException对象。

也许 Java 的未来版本将具体化类型参数,使得指定包含类型参数或实际类型实参的数组创建表达式成为可能。

变元和泛型

当您调用一个 varargs(变量个数)方法,其参数被声明为参数化类型时(如List<String>),编译器会在调用时发出一条警告消息。这条消息可能会引起混淆,并且会阻碍在第三方 API 中使用 varargs。

该警告消息与堆污染有关,当参数化类型的变量引用不属于该参数化类型的对象时,就会出现这种情况。只有当应用执行的操作会在编译时引发未检查的警告时,才会发生堆污染。(《Java 语言规范》,第三版讨论了堆污染的概念[ [java.sun.com/docs/books/jls/third_edition/html/typesValues.html#4.12.2.1](http://java.sun.com/docs/books/jls/third_edition/html/typesValues.html#4.12.2.1) ])。

未检查的警告出现在对其参数类型不可重用的 varargs 方法的调用中。换句话说,由于擦除,参数的类型信息在运行时不能被完全表达。

变量是通过数组实现的,数组是具体化的。换句话说,数组的元素类型存储在内部,并在各种运行时类型检查需要时使用。但是,此存储的类型信息不能包含表示可枚举的参数化类型所需的信息。

当调用方法时,向方法传递物化(和物化)参数化类型的物化数组之间的这种不匹配是未检查警告的核心。

在 Java 5 中,调用这些方法之一会导致编译时警告;声明这样的方法不会导致类似的警告。虽然这种 varargs 方法的存在不会导致堆污染,但它的存在通过提供一种简单的方法来导致堆污染,从而导致堆污染。此外,它通过提供要调用的方法来影响堆污染。因此,导致堆污染的方法声明应该得到编译器警告,就像导致堆污染的方法调用已经得到警告一样。

Java 7 编译器在这两个位置都输出警告,清单 3-58 给出了导致这些警告的场景。

清单 3-58。合并可变数量的StringList

`import java.util.ArrayList;
import java.util.List;

class SafeVarargsDemo
{
   public static void main(String[] args)
   {
      List list1 = new ArrayList<>();
      list1.add("A");
      list1.add("B");
      List list2 = new ArrayList<>();
      list2.add("C");
      list2.add("D");
      list2.add("E");
      System.out.println(merge(list1, list2)); // Output: [A, B, C, D, E]
   }
   //@SafeVarargs
   static List merge(List... lists)
   {
      List mergedLists = new ArrayList<>();
      for (int i = 0; i < lists.length; i++)
         mergedLists.addAll(lists[i]);       return mergedLists;
   }
}`

清单 3-58 声明了一个merge()方法,其目的是将可变数量的String参数的List合并成该方法返回的单个String参数的List。因为擦除将方法的List<String>参数类型转换为List,所以这个数组参数有可能引用一个不存储String对象的List,这是堆污染的一个例子。因此,当您通过javac -Xlint:unchecked SafeVarargsDemo.java编译清单 3-58 时,编译器会发出以下警告:

SafeVarargsDemo.java:15: warning: [unchecked] unchecked generic array creation for varargs parameter of type List<String>[]
      System.out.println(merge(list1, list2)); // Output: [A, B, C, D, E]
                              ^
SafeVarargsDemo.java:18: warning: [unchecked] Possible heap pollution from parameterized vararg type List<String>
   static List<String> merge(List<String>... lists)
                                             ^
2 warnings

merge()方法不做任何会导致ClassCastException的事情。因此,这些警告消息是虚假的,可以通过用@SafeVarargs注释merge()来断言merge()方法的主体不会对其 varargs 参数执行潜在的不安全操作,从而忽略这些警告消息。

取消清单 3-58 中的//@SafeVarargs的注释并重新编译。你会发现这些警告信息消失了。

images 注意各种标准类库方法,比如Arrays类的public static <T> List<T> asList(T... a)方法,被标注为@SafeVarargs,因为当编译器使用适当的类型推断创建它们的 varargs 数组参数时,它们不会抛出ClassCastException s。

列举

一个枚举类型是一个指定相关常量的命名序列作为其合法值的类型。日历中的月份、货币中的硬币和星期几都是枚举类型的例子。

Java 开发人员传统上使用命名整数常量集来表示枚举类型。因为这种表示形式被证明是有问题的,Java 5 引入了 enum 替代方案。

本节向您介绍 enums。在讨论了传统枚举类型的问题之后,本节介绍了枚举的替代方法。然后,它向您介绍了枚举起源的Enum类。

传统枚举类型的麻烦

清单 3-59 声明了一个Coin枚举类型,其常量集标识了一种货币中不同种类的硬币。

清单 3-59。一种枚举型识别硬币

class Coin
{
   final static int PENNY = 0;
   final static int ICKEL = 1;
   final static int DIME = 2;
   final static int QUARTER = 3;
}

清单 3-60 声明了一个Weekday枚举类型,它的常量集标识了一周中的几天。

清单 3-60。标识工作日的枚举类型

class Weekday
{
   final static int SUNDAY = 0;
   final static int MONDAY = 1;
   final static int TUESDAY = 2;
   final static int WEDNESDAY = 3;
   final static int THURSDAY = 4;
   final static int FRIDAY = 5;
   final static int SATURDAY = 6;
}

清单 3-59 和 3-60 表示枚举类型的方法是有问题的,其中最大的问题是缺乏编译时类型安全性。例如,您可以将一枚硬币传递给一个需要工作日的方法,编译器不会抱怨。

你也可以把硬币比作工作日,就像在Coin.NICKEL == Weekday.MONDAY中一样,指定甚至更无意义的表达式,比如Coin.DIME+Weekday.FRIDAY-1/Coin.QUARTER。编译器不会抱怨,因为它只看到了int s

依赖于枚举类型的应用是脆弱的。因为类型的常量被编译到应用的类文件中,所以更改常量的int值需要重新编译相关的应用,否则会有行为不稳定的风险。

枚举类型的另一个问题是int常量不能被翻译成有意义的字符串描述。比如调试一个有故障的应用时 4 是什么意思?能够看到THURSDAY而不是4会更有帮助。

images 注意你可以通过使用String常量来规避前面的问题。例如,您可以指定final static String THURSDAY = "THURSDAY";。虽然常量值更有意义,但是基于String的常量会影响性能,因为您无法使用==有效地比较任何旧字符串(正如您将在第四章中发现的)。与基于String的常量相关的其他问题包括将常量的值("THURSDAY")而不是常量的名称(THURSDAY)硬编码到源代码中,这使得以后很难更改常量的值;以及拼错了一个硬编码的常量("THURZDAY"),它可以正确编译,但在运行时会有问题。

枚举替代

Java 5 引入了枚举作为传统枚举类型的更好替代。enum 是通过保留字enum表示的枚举类型。下面的例子使用enum来声明清单 3-59 和清单 3-60 的枚举类型:

enum Coin { PENNY, ICKEL, DIME, QUARTER }
enum Weekday { SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY }

尽管它们与 C++和其他语言中基于int的枚举类型相似,但这个例子的枚举是类。每个常量都是一个public static final字段,表示其枚举类的一个实例。

因为常量是最终的,并且因为您不能调用枚举的构造函数来创建更多的常量,所以您可以使用==来高效且安全地比较常量(不同于字符串常量比较)。比如可以指定c == Coin.NICKEL

枚举通过防止您比较不同枚举中的常数来提高编译时类型安全性。比如编译器遇到Coin.PENNY == Weekday.SUNDAY会报错。

编译器也不赞成将错误枚举类型的常数传递给方法。例如,您不能将Weekday.FRIDAY传递给参数类型为Coin的方法。

依赖枚举的应用并不脆弱,因为枚举的常量并没有编译到应用的类文件中。同样,enum 提供了一个toString()方法来返回一个更有用的常量值的描述。

因为枚举非常有用,Java 5 增强了 switch 语句来支持它们。清单 3-61 演示了该语句在前面例子的Coin枚举中打开一个常量。

清单 3-61。使用带有枚举的 switch 语句

class EnhancedSwitch {    enum Coin { PENNY, ICKEL, DIME, QUARTER }    public static void main(String[] args)    {       Coin coin = Coin.NICKEL;       switch (coin)       {          case PENNY  : System.out.println("1 cent"); break;          case ICKEL : System.out.println("5 cents"); break;          case DIME   : System.out.println("10 cents"); break;          case QUARTER: System.out.println("25 cents"); break;          default     : assert false;       }    } }

清单 3-61 演示了打开一个枚举的常量。这个增强的语句只允许您将常量的名称指定为 case 标签。如果您在名称前加上 enum,如在case Coin.DIME中,编译器会报告一个错误。

增强枚举

您可以向枚举添加字段、构造函数和方法,甚至可以让枚举实现接口。例如,清单 3-62 向Coin添加了一个字段、一个构造函数和两个方法,以将一个面额值与一个Coin常量相关联(比如 1 代表便士,5 代表 ickel)并将便士转换成面额。

清单 3-62。增强Coin枚举

enum Coin
{
   PENNY(1),
   NICKEL(5),
   DIME(10),
   QUARTER(25);

   private final int denomValue;
   Coin(int denomValue)
   {
      this.denomValue = denomValue;
   }
   int denomValue()
   {
      return denomValue;
   }
   int toDenomination(int umPennies)
   {
      return umPennies/denomValue;
   }
}

清单 3-62 的构造函数接受一个命名值,它将该值赋给一个名为denomValue的空白最终字段private——所有字段都应该声明为final,因为常量是不可变的。注意,这个值在创建过程中被传递给每个常量(例如,PENNY(1))。

images 注意当逗号分隔的常量列表后跟除枚举右括号以外的任何内容时,必须用分号终止列表,否则编译器会报告错误。

此外,这个清单的denomValue()方法返回denomValue,它的toDenomination()方法返回该面值硬币的数量,这些硬币包含在作为参数传递给这个方法的便士数量中。例如,16 个便士中包含 3 个镍币。

清单 3-63 展示了如何使用增强的Coin枚举。

清单 3-63。锻炼增强型Coin枚举

class Coins
{
   public static void main(String[] args)
   {
      if (args.length == 1)
      {
         int umPennies = Integer.parseInt(args[0]);
         System.out.println(numPennies+" pennies is equivalent to:");
         int umQuarters = Coin.QUARTER.toDenomination(numPennies);
         System.out.println(numQuarters+" "+Coin.QUARTER.toString()+
                            (numQuarters != 1 ? "s," : ","));
         numPennies -= umQuarters*Coin.QUARTER.denomValue();
         int umDimes = Coin.DIME.toDenomination(numPennies);
         System.out.println(numDimes+" "+Coin.DIME.toString()+
                            (numDimes != 1 ? "s, " : ","));
         numPennies -= umDimes*Coin.DIME.denomValue();
         int umNickels = Coin.NICKEL.toDenomination(numPennies);
         System.out.println(numNickels+" "+Coin.NICKEL.toString()+
                            (numNickels != 1 ? "s, " : ", and"));
         numPennies -= umNickels*Coin.NICKEL.denomValue();
         System.out.println(numPennies+" "+Coin.PENNY.toString()+
                            (numPennies != 1 ? "s" : ""));
      }
      System.out.println();
      System.out.println("Denomination values:");
      for (int i = 0; i < Coin.values().length; i++)
         System.out.println(Coin.values()[i].denomValue());
   }
}

清单 3-63 描述了一个应用,它将单独的“便士”命令行参数转换成以 25 美分、10 美分、5 美分和 1 美分表示的等价金额。除了调用Coin常量的denomValue()toDenomValue()方法,应用还调用toString()来输出硬币的字符串表示。

另一个被调用的 enum 方法是values()。该方法返回在Coin枚举中声明的所有Coin常量的数组(在本例中,value()的返回类型是Coin[])。当您需要迭代这些常量时,这个数组非常有用。例如,清单 3-63 调用这个方法来输出每枚硬币的面额。

当您使用119作为命令行参数(java Coins 119)运行这个应用时,它会生成以下输出:

`119 pennies is equivalent to:
4 QUARTERs,
1 DIME,
1 ICKEL, and
4 PENNYs

Denomination values: 1
5
10
25`

输出显示toString()返回一个常量的名称。重写此方法以返回更有意义的值有时很有用。例如,从字符串中提取标记(命名字符序列)的方法可能使用Token枚举来列出标记名,并通过一个覆盖的toString()方法来列出值——参见清单 3-64 。

清单 3-64覆盖toString()返回一个Token常量的值

enum Token
{
   IDENTIFIER("ID"),
   INTEGER("INT"),
   LPAREN("("),
   RPAREN(")"),
   COMMA(",");
   private final String tokValue;
   Token(String tokValue)
   {
      this.tokValue = tokValue;
   }
   @Override
   public String toString()
   {
      return tokValue;
   }
   public static void main(String[] args)
   {
      System.out.println("Token values:");
      for (int i = 0; i < Token.values().length; i++)
         System.out.println(Token.values()[i].name()+" = "+
                            Token.values()[i]);
   }
}

清单 3-64 的main()方法调用values()返回Token常量数组。对于每个常量,它调用常量的name()方法返回常量的名称,并隐式调用toString()返回常量的值。如果您要运行此应用,您将会看到以下输出:

Token values:
IDENTIFIER = ID
INTEGER = INT
LPAREN = (
RPAREN = )
COMMA = ,

增强枚举的另一种方法是给每个常量分配不同的行为。您可以通过在枚举中引入一个抽象方法并在常量的匿名子类中重写该方法来完成此任务。清单 3-65 的TempConversion枚举展示了这种技术。

清单 3-65。使用匿名子类改变枚举常量的行为

enum TempConversion
{
   C2F("Celsius to Fahrenheit")
   {
      @Override
      double convert(double value)
      {
         return value*9.0/5.0+32.0;
      }
   },
   F2C("Fahrenheit to Celsius")
   {
      @Override
      double convert(double value)
      {
         return (value-32.0)*5.0/9.0;
      }
   };
   TempConversion(String desc)
   {
      this.desc = desc;
   }
   private String desc;
   @Override
   public String toString()
   {
      return desc;
   }
   abstract double convert(double value);
   public static void main(String[] args)
   {
      System.out.println(C2F+" for 100.0 degrees = "+C2F.convert(100.0));
      System.out.println(F2C+" for 98.6 degrees = "+F2C.convert(98.6));
   }
}

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

Celsius to Fahrenheit for 100.0 degrees = 212.0
Fahrenheit to Celsius for 98.6 degrees = 37.0

枚举类

编译器将enum视为语法糖。当它遇到一个枚举类型声明(enum Coin {})时,它生成一个类,其名称(Coin)由声明指定,并且它还子类化抽象的Enum类(在java.lang包中),所有基于 Java 的枚举类型的公共基类。

如果你检查一下Enum的 Java 文档,你会发现它覆盖了Objectclone()equals()finalize()hashCode()toString()方法:

  • clone()被覆盖,以防止常数被克隆,从而使得常数有多个副本;否则,常量无法通过==进行比较。
  • equals()被覆盖,通过引用比较常量—相同恒等式(==)的常量必须有相同的内容(equals()),不同的恒等式意味着不同的内容。
  • finalize()被覆盖,以确保常数不能被最终确定。
  • 因为equals()被覆盖,所以hashCode()被覆盖。
  • toString()被覆盖以返回常量的名称。

除了toString(),所有的覆盖方法都被声明为final,这样它们就不能在子类中被覆盖。

Enum也提供了自己的方法。这些方法包括finalEnum实现ComparablegetDeclaringClass()name()ordinal()方法:

  • compareTo()将当前常数与作为参数传递的常数进行比较,以查看在枚举中哪个常数在另一个常数之前,并返回一个值来指示它们的顺序。这个方法可以对未排序的常量数组进行排序。
  • getDeclaringClass()返回当前常量枚举对应的Class对象。例如,当调用enum Coin { PENNY, ICKEL, DIME, QUARTER }Coin.PENNY.getDeclaringClass()时,返回CoinClass对象。同样,当调用【清单 3-65 的TempConversion枚举的TempConversion.C2F.getDeclaringClass()时,返回TempConversioncompareTo()方法使用ClassgetClass()方法和EnumgetDeclaringClass()方法来确保只比较属于同一个枚举的常量。否则,抛出一个ClassCastException。(我会在第四章的中讨论Class。)
  • name()返回常量的名称。除非被覆盖以返回更具描述性的内容,toString()还会返回常量的名称。
  • ordinal()返回一个从零开始的序号,一个标识枚举类型中常量位置的整数。compareTo()比较序数。

Enum还提供了public static <T extends Enum<T>> T valueOf(Class<T> enumType, String name)方法,用于从具有指定名称的指定枚举中返回枚举常量:

  • enumType标识从其返回常数的枚举的Class对象。
  • name标识要返回的常量的名称。

例如,Coin penny = Enum.valueOf(Coin.class, "PENNY");将名为PENNYCoin常量赋给penny

你不会在Enum的 Java 文档中发现一个values()方法,因为编译器在生成类时合成了(制造)这个方法。

扩展枚举类

Enum的通用类型是Enum<E extends Enum<E>>。尽管形式类型参数列表看起来很可怕,但理解起来并不难。但是首先,看一下清单 3-66 。

清单 3-66。从其类文件的角度来看的Coin

final class Coin extends Enum<Coin>
{
   public static final Coin PENNY = new Coin("PENNY", 0);
   public static final Coin ICKEL = new Coin("NICKEL", 1);
   public static final Coin DIME = new Coin("DIME", 2);
   public static final Coin QUARTER = new Coin("QUARTER", 3);
   private static final Coin[] $VALUES = { PENNY, ICKEL, DIME, QUARTER };
   public static Coin[] values()
   {
      return Coin.$VALUES.clone();
   }
   public static Coin valueOf(String name)
   {
      return Enum.valueOf(Coin.class, "Coin");
   }
   private Coin(String name, int ordinal)
   {
      super(name, ordinal);
   }
}

在幕后,编译器将enum Coin { PENNY, ICKEL, DIME, QUARTER }转换成类似于清单 3-66 的类声明。

以下规则向您展示了如何在Coin extends Enum<Coin>的上下文中解释Enum<E extends Enum<E>>:

  • 任何Enum的子类都必须为Enum提供一个实际的类型参数。例如,Coin的头指定了Enum<Coin>
  • 实际的类型参数必须是Enum的子类。例如,CoinEnum的子类。
  • Enum(比如Coin)的子类必须遵循这样的习惯用法:它提供自己的名字(Coin)作为实际的类型参数。

第三条规则允许Enum声明方法——compareTo()getDeclaringClass()valueOf()——它们的参数和/或返回类型是根据子类Coin而不是根据Enum指定的。这样做的理由是为了避免必须指定强制转换。例如,您不需要将valueOf()的返回值强制转换为Coin penny = Enum.valueOf(Coin.class, "PENNY");中的Coin

images 注意你不能编译清单 3-66 ,因为编译器不会编译任何扩展Enum的类。它也会抱怨super(name, ordinal);

练习

以下练习旨在测试您对嵌套类型、包、静态导入、异常、断言、注释、泛型和枚举的理解:

  1. 2D 图形软件包支持二维绘图和转换(旋转、缩放、平移等)。这些转换需要一个 3x 3 的矩阵(一个表格)。声明一个包含一个private Matrix非静态成员类的G2D类。除了声明一个Matrix(int rows, int cols)构造函数,Matrix还声明了一个void dump()方法,以表格格式将矩阵值输出到标准输出。在G2D的无参数构造函数中实例化Matrix,将Matrix实例初始化为单位矩阵(一个矩阵,除了左上角到右下角的元素为 1 外,其他元素都为 0。然后从构造函数中调用这个实例的dump()方法。包括一个main()方法来测试G2D

  2. 扩展logging包以支持一个空设备,其中的消息被丢弃。

  3. Continuing from Exercise 1, introduce the following matrix-multiplication method into Matrix:               Matrix multiply(Matrix m)               {                  Matrix result = new Matrix(matrix.length, matrix[0].length);                  for (int i = 0; i < matrix.length; i++)                     for (int j = 0; j < m.matrix[0].length; j++)                        for (int k = 0; k < m.matrix.length; k++)                           result.matrix[i][j] = result.matrix[i][j]+                                                 matrix[i][k]*m.matrix[k][j];                  return result;               }

    接下来,在G2D中声明一个void rotate(double angle)方法。这个方法的第一个任务是否定它的angle参数(确保逆时针旋转),该参数指定了以度为单位的旋转角度。然后,它创建一个 3 乘 3 的旋转Matrix,并初始化以下(行,列)条目:(0,0)为角度的余弦,(1,0)为角度的正弦,(0,1)为角度正弦的负值,(1,1)为角度的余弦,以及(2,2)为 1.0。静态导入所有必需的Math类方法。最后,rotate()将在G2D的构造器中创建的单位矩阵乘以这个旋转矩阵,并调用dump()来转储结果。通过执行G2D g2d = new G2D(); g2d.rotate(45);main()方法中测试rotate()。您应该观察到以下输出:

    `1.0 0.0 0.0
    0.0 1.0 0.0
    0.0 0.0 1.0

    0.7071067811865476 0.7071067811865475 0.0 -0.7071067811865475 0.7071067811865476 0.0
    0.0 0.0 1.0`

  4. 修改logging包,使得Loggerconnect()方法在无法连接到其日志目的地时抛出CannotConnectException,另外两个方法在connect()未被调用或抛出CannotConnectException时各抛出NotConnectedException。修改TestLogger以适当地响应抛出的CannotConnectExceptionNotConnectedException对象。

  5. 继续练习 3,在G2D的构造函数结束之前,使用断言来验证转换矩阵被初始化为单位矩阵的类不变量。

  6. 声明一个ToDo标记注释类型,它只注释类型元素,并且还使用默认的保留策略。

  7. 重写StubFinder应用以使用清单 3-43 的Stub注释类型(带有适当的@Target@Retention注释)和清单 3-44 的Deck类。

  8. 以类似于清单 3-52 的Queue类的方式实现一个Stack<E>通用类型。Stack必须声明push()pop()isEmpty()方法(它也可以声明一个isFull()方法,但该方法在本练习中不是必需的),push()必须在栈满时抛出一个StackFullException实例,而pop()必须在栈空时抛出一个StackEmptyException实例。(您必须创建自己的StackFullExceptionStackEmptyException助手类,因为 Java 的标准类库中没有为您提供它们。)声明一个类似的main()方法,并在该方法中插入两个断言,以验证您关于堆栈在创建后立即为空以及弹出最后一个元素后立即为空的假设。

  9. NORTHSOUTHEASTWEST成员声明一个Compass枚举。声明一个UseCompass类,它的main()方法随机选择这些常量中的一个,然后打开那个常量。switch 语句的每个案例都应该输出一条消息,比如heading north

总结

Java 支持与嵌套类型、包、静态导入、异常、断言、注释、泛型和枚举相关的高级语言特性。

在任何类之外声明的类称为顶级类。Java 还支持嵌套类,嵌套类是声明为其他类或作用域的成员的类,它帮助您实现顶级类架构。

有四种嵌套类:静态成员类、非静态成员类、匿名类和本地类。后三类被称为内部类。

Java 支持将顶级类型划分为多个名称空间,以更好地组织这些类型,并防止名称冲突。Java 使用包来完成这些任务。

package 语句标识源文件的类型所在的包。import 语句通过告诉编译器在编译过程中何处查找非限定类型名来从包中导入类型。

异常是与应用正常行为的差异。尽管可以用错误代码或对象来表示,但是 Java 使用对象,因为错误代码没有意义,并且不能包含导致异常的信息。

Java 提供了表示不同类型异常的类的层次结构。这些类根植于Throwable。沿着 throwable 层次结构向下,您会遇到ExceptionError类,它们代表非错误异常和错误。

Exception及其子类,除了RuntimeException(及其子类)描述了被检查的异常。之所以检查它们,是因为编译器会检查代码,以确保异常在抛出或被识别为在其他地方处理时得到处理。

RuntimeException及其子类描述未检查的异常。您不必处理这些异常,因为它们代表编码错误(修复错误)。尽管它们的类名可以出现在 throws 子句中,但这样做会增加混乱。

throw 语句向 JVM 抛出一个异常,JVM 会搜索一个合适的处理程序。如果检查了异常,它的名称必须出现在方法的 throws 子句中,除非异常的超类的名称在该子句中列出。

方法通过指定 try 语句和适当的 catch 块来处理一个或多个异常。无论是否引发异常,以及在引发的异常离开方法之前,都可以包含 finally 块来执行清理代码。

断言是一种语句,它允许您通过布尔表达式来表达应用正确性的假设。如果该表达式的计算结果为 true,则继续执行 ext 语句;否则,将引发一个标识失败原因的错误。

在很多情况下都应该使用断言。这些情况被组织成内部不变量、控制流不变量和契约设计类别。不变量是不变的东西。

尽管有很多情况下应该使用断言,但也有一些情况下应该避免使用断言。例如,您不应该使用断言来检查传递给公共方法的参数。

编译器在类文件中记录断言。但是,断言在运行时被禁用,因为它们会影响性能。在测试关于类行为的假设之前,必须启用类文件的断言。

注释是注释类型的实例,并将元数据与应用元素相关联。它们在源代码中是通过在类型名前加上@符号来表达的。例如,@Readonly是一个注释,Readonly是它的类型。

Java 提供了各种各样的注释类型,包括面向编译器的OverrideDeprecatedSuppressWarningsSafeVarargs类型。然而,您也可以通过使用@interface语法来声明自己的注释类型。

注释类型可以用元注释进行注释,元注释标识它们可以作为目标的应用元素(例如构造函数、方法或字段)、它们的保留策略以及其他特征。

通过@Retention标注为其类型分配了运行时保留策略的标注,可以使用定制应用或 Java 的apt工具在运行时进行处理,其功能已经集成到从 Java 6 开始的编译器中。

Java 5 引入了泛型,这是声明和使用与类型无关的类和接口的语言特性。当使用 Java 的集合框架时,这些特性可以帮助您避免ClassCastException s。

泛型类型是一个类或接口,它通过声明一个正式的类型参数列表来引入一系列参数化类型。替换类型参数的类型名称为实际类型参数。

有五种实际类型参数:具体类型、具体参数化类型、数组类型、类型参数和通配符。此外,泛型类型还标识原始类型,即没有类型参数的泛型类型。

许多类型参数是无限的,因为它们可以接受任何实际的类型参数。若要限制实际类型参数,可以指定一个上限,该类型作为可选择作为实际类型参数的类型的上限。上限是通过保留字extends后跟类型名来指定的。但是,不支持下限。

除非被屏蔽,否则类型参数的范围是其泛型类型。此范围包括类型参数是其成员的形式类型参数列表。

为了保护类型安全,不允许违反泛型类型的基本规则:对于类型 y 的给定子类型 x ,并且给定 G 作为原始类型声明, G < x > 不是 G < y > 的子类型。换句话说,仅在一个类型参数是另一个类型参数的子类型方面不同的多个参数化类型不是多态的。比如List<String>就不是List<Object>的专门种类。

通过使用通配符,可以在不违反类型安全的情况下命名此限制。例如,void output(List<Object> list)方法只能输出包含ObjectList(为了遵守前述规则),而void output(List<?> list)方法可以输出任意对象的List

单独使用通配符无法解决将一个List复制到另一个的问题。解决方案是使用一个泛型方法,一个具有类型泛化实现的static或 on- static方法。例如,<T> void copyList(List<T> src, List<T> dest)方法可以将任意对象(其类型由T指定)的源List复制到任意对象(具有相同类型)的另一个List。编译器从调用方法的上下文中推断实际的类型参数。

具体化是将抽象表示为具体的——例如,使内存地址可供其他语言结构直接操作。Java 数组是具体化的,因为它们知道自己的元素类型(元素类型存储在内部),并且可以在运行时强制使用这些类型。试图在数组中存储无效元素会导致 JVM 抛出一个ArrayStoreException类的实例。

与数组不同,泛型类型的类型参数没有具体化。它们在运行时是不可用的,因为它们在源代码编译后就被扔掉了。这种“丢弃类型参数”是擦除的结果,这也涉及到当代码类型不正确时插入对适当类型的强制转换,并用类型参数的上限替换它们(例如Object)。

当你调用一个参数被声明为参数化类型的 varargs 方法时(如List<String>),编译器会在调用时发出一条警告消息。这条消息可能会引起混淆,并且会阻碍在第三方 API 中使用 varargs。

该警告消息与堆污染有关,当参数化类型的变量引用不属于该参数化类型的对象时,会发生堆污染。

如果声明了一个 varargs 方法,导致出现这个警告消息,并且如果 varargs 方法没有对它的 varargs 参数执行潜在的不安全操作,那么您可以对方法@SafeVarargs进行注释,并消除这个警告消息。

枚举类型是将相关常数的命名序列指定为合法值的类型。Java 开发人员传统上使用命名整数常量集来表示枚举类型。

因为命名整数常量集被证明是有问题的,Java 5 引入了 enum 替代方法。enum 是通过保留字enum表示的枚举类型。

您可以向 enum 添加字段、构造函数和方法——您甚至可以让 enum 实现接口。此外,您可以覆盖toString()来提供一个更有用的常量值描述,并子类化常量来分配不同的行为。

编译器将enum视为一个子类Enum的语法糖。这个抽象类覆盖了各种Object方法来提供默认行为(通常是出于安全原因),并为各种目的提供了额外的方法。

本章大体上完成了我们对 Java 语言的探索。然而,还有一些更高级的语言特性需要探索。在第四章中,你会遇到几个这样的小特性,这是对位于 Java 标准类库中的额外类型的多章节探索的开始。

一、Java 语言 API

Java 的标准类库提供了各种面向语言的 API。这些 API 中的大多数驻留在java.lang包及其子包中,尽管有少数 API 驻留在java.math中。第四章先给大家介绍一下java.lang/子包MathStrictMathPackage,原语类型包装类、引用、反射、StringStringBufferStringBuilderSystem,以及线程 API。本章然后向您介绍java.mathBigDecimalBigIntegerAPI。

数学和严格数学

java.lang.Math类声明了表示自然对数底值(2.71828)的double常量EPI...)和圆周与其直径的比率(3.14159...).E被初始化为2.718281828459045,而PI被初始化为3.141592653589793Math还声明了各种类方法来执行各种数学运算。表 4-1 描述了其中的许多方法。

images

images

images

images

表 4-1 揭示了各种各样有用的面向数学的方法。例如,每个abs()方法返回其参数的绝对值(不考虑符号的数字)。

abs(double)abs(float)用于安全地比较双精度浮点和浮点值。例如,0.3 == 0.1+0.1+0.1的计算结果为 false,因为 0.1 没有精确的表示。但是,您可以将这些表达式与abs()和一个公差值进行比较,公差值表示可接受的误差范围。例如,Math.abs(0.3-(0.1+0.1+0.1)) < 0.1返回 true,因为0.30.1+0.1+0.1之间的绝对差值小于 0.1 容差值。

前几章演示了其他的Math方法。例如,第二章演示了Mathrandom()sin()cos()toRadians()方法。

正如第三章的Lotto649应用所揭示的那样,random()(它返回一个看似随机选择的数字,但实际上是通过可预测的数学计算选择的,因此是伪随机)在模拟中(以及在游戏和任何需要概率元素的地方)是有用的。然而,它的 0.0 到(几乎)1.0 的双精度浮点范围并不实用。为了让random()更有用,它的返回值必须转换成更有用的范围,可能是 0 到 49 的整数值,也可能是-100 到 100。您会发现下面的rnd()方法有助于进行这些转换:

static int rnd(int limit)
{
   return (int) (Math.random()*limit);
}

rnd()random()的 0.0 到(几乎)1.0 双精度浮点范围转换为 0 到limit -1 的整数范围。例如,rnd(50)返回 0 到 49 之间的整数。另外,-100+rnd(201)通过添加一个合适的偏移量并传递一个合适的limit值,将 0.0 到(几乎)1.0 转换成-100 到 100。

images 注意不要指定(int) Math.random()*limit,因为这个表达式的值总是 0。该表达式首先转换random()的双精度浮点小数值(0.0 到 0.99999。。。)通过截断小数部分转换为整数 0,然后将 0 乘以limit,得到 0。

sin()cos()方法实现正弦和余弦三角函数——参见[en.wikipedia.org/wiki/Trigonometric_functions](http://en.wikipedia.org/wiki/Trigonometric_functions)。这些函数的用途从研究三角形到模拟周期现象(如简谐运动——见[en.wikipedia.org/wiki/Simple_harmonic_motion](http://en.wikipedia.org/wiki/Simple_harmonic_motion))。

我们可以用sin()cos()来产生和显示正弦波和余弦波。清单 4-1 给出了一个应用的源代码。

清单 4-1。绘制正弦和余弦波

class Graph {    final static int ROWS = 11; // Must be odd    final static int COLS= 23;    public static void main(String[] args)    {       char[][] screen = new char[ROWS][];       for (int row = 0; row < ROWS; row++)          screen[row] = new char[COLS];       double scaleX = COLS/360.0;       for (int degree = 0; degree < 360; degree++)       {          int row = ROWS/2+                    (int) Math.round(ROWS/2*Math.sin(Math.toRadians(degree)));          int col = (int) (degree*scaleX);          screen[row][col] = 'S';          row = ROWS/2+                (int) Math.round(ROWS/2*Math.cos(Math.toRadians(degree)));          screen[row][col] = (screen[row][col] == 'S') ? '*' : 'C';       }       for (int row = ROWS-1; row >= 0; row--)       {          for (int col = 0; col < COLS; col++)             System.out.print(screen[row][col]);          System.out.println();       }    } }

清单 4-1 引入了一个Graph类,它首先声明了一对常量:NROWSNCOLS。这些常量指定在其上生成图形的数组的维数。NROWS必须赋一个奇整数;否则,抛出一个java.lang.ArrayIndexOutOfBoundsException类的实例。

提示尽可能使用常量是个好主意。源代码更容易维护,因为您只需要在一个地方更改常量的值,而不必在整个源代码中更改每个相应的值。

Graph next 声明了它的main()方法,该方法首先创建一个字符的二维screen数组。这个数组用于模拟一个老式的基于字符的屏幕来查看图形。

main()接下来计算水平比例值,用于水平缩放每个图形,以便 360 个水平(度)位置适合NCOLS指定的列数。

继续,main()进入 for 循环,对于每个正弦和余弦图形,为每个度数值创建(行,列)坐标,并在这些坐标处为screen数组分配一个字符。正弦图的字符为S,余弦图的字符为C,余弦图与正弦图相交时的字符为*

行计算调用toRadians()将其degree参数转换为弧度,这是sin()cos()方法所需要的。从sin()cos() (-1 到 1)返回的值然后乘以ROWS/2以将该值缩放为screen数组中行数的一半。在通过long round(double d)方法将结果舍入到最接近的长整数后,使用强制转换将长整数转换为整数,并将该整数添加到ROW/2中以偏移行坐标,使其相对于数组的中间行。列计算更简单,将度数值乘以水平比例因子。

screen数组通过一对嵌套的 for 循环转储到标准输出设备。外部 for 循环反转屏幕,使其正面朝上——第 0 行应该最后输出。

编译清单 4-1 ( javac Graph.java)并运行应用(java Graph)。您会看到以下输出:

CC  SSSS             CC
 CSSS  SS           CC
 S*C    SS         CC  
 S CC    SS       CC   
SS  CC    SS     CC    
S    CC    S    CC    S
      C    SS   C    SS
      CC    SS CC    S
       CC    SCC    SS
        CC   CSS  SSS  
         CCCCC SSSS        

images 注意当我创建screen数组时,我利用了每个元素都被初始化为 0 的事实,这被解释为空字符。当一个System.out.print()System.out.println()方法检测到这个字符时,它输出一个空格字符。

表 4-1 还揭示了几个以+无穷大、-无穷大、+0.0、-0.0、NaN(非数字)开头的奇闻。

Java 的浮点计算能够返回+infinity、-infinity、+0.0、-0.0 和 NaN,因为 Java 很大程度上符合 IEEE 754 ( [en.wikipedia.org/wiki/IEEE_754](http://en.wikipedia.org/wiki/IEEE_754)),这是一种浮点计算的标准。以下是产生这些特殊值的情况:

  • +infinity 返回试图将一个正数除以 0.0 的结果。比如System.out.println(1.0/0.0);输出Infinity
  • -infinity 从试图将负数除以 0.0 返回。比如System.out.println(-1.0/0.0);输出-Infinity
  • NaN 从尝试将 0.0 除以 0.0、尝试计算负数的平方根以及尝试其他奇怪的运算返回。比如System.out.println(0.0/0.0);System.out.println(Math.sqrt(-1.0));各自输出NaN
  • +0.0 是试图将正数除以+无穷大的结果。比如System.out.println(1.0/(1.0/0.0));输出+0.0
  • -0.0 是试图将负数除以+无穷大的结果。比如System.out.println(-1.0/(1.0/0.0));输出-0.0

一旦运算产生+无穷大、-无穷大或 NaN,表达式的其余部分通常等于该特殊值。比如System.out.println(1.0/0.0*20.0);输出Infinity。此外,首先产生+无穷大或-无穷大的表达式可能会转化为 NaN。例如,1.0/0.0*0.0产生+无穷大(1.0/0.0,然后是 NaN(+无穷大*0.0)。

另一个好奇心是Integer.MAX_VALUEInteger.MIN_VALUELong.MAX_VALUELong.MIN_VALUE。这些项中的每一项都是一个基元类型包装类常数,它标识可由该类的关联基元类型表示的最大值或最小值。(我将在本章后面讨论基本类型包装类。)

最后,你可能想知道为什么abs()max()min()重载方法不包括byteshort版本,就像在byte abs(byte b)short abs(short s)中一样。不需要这些方法,因为字节和短整数的有限范围使它们不适合计算。如果你需要这样的方法,查看清单 4-2 。

清单 4-2。获取字节整数和短整数的绝对值

class AbsByteShort
{
   static byte abs(byte b)
   {
      return (b < 0) ? (byte) -b : b;
   }
   static short abs(short s)
   {
      return (s < 0) ? (short) -s : s;
   }
   public static void main(String[] args)
   {
      byte b = -2;
      System.out.println(abs(b)); // Output: 2
      short s = -3;
      System.out.println(abs(s)); // Output: 3
   }
}

清单 4-2 的(byte)(short)转换是必要的,因为-bb的值从byte转换为int,而-ss的值从short转换为int。相比之下,(b < 0)(s < 0)不需要这些类型转换,它们在与基于int0进行比较之前,会自动将bs的值转换为一个int

images 提示它们在Math中的缺席表明byteshort在方法声明中不是很有用。但是,当声明其元素存储小值(如二进制文件的字节值)的数组时,这些类型很有用。如果您声明了一个由intlong组成的数组来存储这些值,那么您最终会浪费堆空间(甚至可能会耗尽内存)。

在 Java 文档中搜索java.lang包时,您可能会遇到一个名为StrictMath的类。除了一个更长的名字,这个职业看起来和Math一样。这些类别之间的差异可以总结如下:

  • StrictMath的方法在所有平台上返回完全相同的结果。相比之下,Math的一些方法可能会返回因平台不同而略有不同的值。
  • 因为StrictMath不能利用平台特定的特性,比如扩展精度的数学协处理器,所以StrictMath的实现可能不如Math的实现有效。

在大多数情况下,Math的方法调用它们的StrictMath对应物。两个例外是toDegrees()toRadians()。尽管这些方法在两个类中有相同的代码体,但是StrictMath的实现在方法头中包含了保留字strictfp:

public static strictfp double toDegrees(double angrad)
public static strictfp double toRadians(double angdeg)

维基百科的“strictfp”条目(http://en.wikipedia.org/wiki/Strictfp)提到strictfp限制浮点计算以保证可移植性。这个保留字在中间浮点表示和上溢/下溢(生成太大或太小而不适合表示的值)的上下文中实现了可移植性。

images 注意之前引用的“strictfp”文章中说Math包含了public static strictfp double abs(double);和其他strictfp方法。如果你在 Java 7 下检查这个类的源代码,你不会在源代码的任何地方找到strictfp。然而,许多Math方法(如sin())调用它们的StrictMath对应方法,这些方法是在特定于平台的库中实现的,并且该库的方法实现是严格的。

如果没有strictfp,中间计算就不局限于 Java 支持的 IEEE 754 32 位和 64 位浮点表示。相反,计算可以在支持这种表示的平台上利用更大的表示(可能是 128 位)。

当中间计算的值以 32/64 位表示时会上溢/下溢,而当其值以更多位表示时可能不会上溢/下溢。由于这种差异,可移植性受到了损害。strictfp通过要求所有平台使用 32/64 位进行中间计算来平衡竞争环境。

当应用于一个方法时,strictfp确保在该方法中执行的所有浮点计算都是严格符合的。然而,strictfp可以在类头声明中使用(如在public strictfp class FourierTransform中),以确保该类中执行的所有浮点计算都是严格的。

images 注意 MathStrictMath被声明为final,所以不能扩展。此外,它们声明私有的空无参数构造函数,因此它们不能被实例化。最后,MathStrictMath是工具类的例子,因为它们作为static方法的占位符存在。

包装

java.lang.Package类提供了对包信息的访问(见第三章关于包的介绍)。这些信息包括关于 Java 包的实现和规范的版本细节、包的名称,以及包是否已经被密封的指示(包中的所有类都归档在同一个 JAR 文件中)。

表 4-2 描述了Package的一些方法。

images

我已经创建了一个PackageInfo应用,演示了大多数表 4-2 的Package方法。清单 4-3 展示了这个应用的源代码。

清单 4-3。获取包裹信息

class PackageInfo
{
   public static void main(String[] args)
   {
      if (args.length == 0)
      {
         System.err.println("usage: java PackageInfo packageName [version]");
         return;
      }
      Package pkg = Package.getPackage(args[0]);
      if (pkg == null)
      {
         System.err.println(args[0]+" not found");
         return;
      }
      System.out.println("Name: "+pkg.getName());
      System.out.println("Implementation title: "+
                         pkg.getImplementationTitle());
      System.out.println("Implementation vendor: "+
                         pkg.getImplementationVendor());
      System.out.println("Implementation version: "+
                         pkg.getImplementationVersion());
      System.out.println("Specification title: "+
                         pkg.getSpecificationTitle());
      System.out.println("Specification vendor: "+
                         pkg.getSpecificationVendor());
      System.out.println("Specification version: "+
                         pkg.getSpecificationVersion());
      System.out.println("Sealed: "+pkg.isSealed());
      if (args.length > 1)
         System.out.println("Compatible with "+args[1]+": "+
                            pkg.isCompatibleWith(args[1]));
   }
}

编译完清单 4-3 ( javac PackageInfo.java)之后,在运行这个应用时,在命令行上至少指定一个包名。例如,java PackageInfo java.lang在 Java 7 下返回以下输出:

Name: java.lang Implementation title: Java Runtime Environment Implementation vendor: Oracle Corporation Implementation version: 1.7.0 Specification title: Java Platform API Specification Specification vendor: Oracle Corporation Specification version: 1.7 Sealed: false

PackageInfo还允许您确定软件包的规范是否与特定的版本号兼容。包与其前身兼容。

例如,java PackageInfo java.lang 1.7输出Compatible with 1.7: true,而java PackageInfo java.lang 1.8输出Compatible with 1.8: false

你也可以在你自己的包中使用PackageInfo,这些包是你在第三章中学到的。例如,那一章提出了一个logging包。

PackageInfo.class复制到包含logging包目录(包含编译后的类文件)的目录下,执行java PackageInfo logging

PackageInfo通过显示以下输出进行响应:

logging not found

出现这个错误消息是因为getPackage()在返回一个描述这个包的Package对象之前,需要从这个包加载至少一个 classfile。

消除前面错误信息的唯一方法是从包中加载一个类。通过将下面的代码片段合并到清单 4-3 中来完成这项任务。

if (args.length == 3)
try
{
   Class.forName(args[2]);
}
catch (ClassNotFoundException cnfe)
{
   System.err.println("cannot load "+args[2]);
   return;
}

这段代码片段必须在Package pkg = Package.getPackage(args[0]);之前,它加载由修改后的PackageInfo应用的第三个命令行参数命名的类文件。(我将在本章后面讨论Class.forName()。)

通过java PackageInfo logging 1.5 logging.File运行新的PackageInfo应用,您将观察到以下输出——该命令行将loggingFile类标识为要加载的类:

Name: logging
Implementation title: null
Implementation vendor: null
Implementation version: null
Specification title: null
Specification vendor: null
Specification version: null
Sealed: false
Exception in thread "main" java.lang.NumberFormatException: Empty version string
        at java.lang.Package.isCompatibleWith(Package.java:228)
        at PackageInfo.main(PackageInfo.java:42)

看到所有这些null值并不奇怪,因为没有包信息被添加到logging包中。此外,NumberFormatExceptionisCompatibleWith()抛出,因为logging包不包含点格式的规范版本号(为空)。

也许将包信息放入logging包的最简单的方法是以类似于第三章中所示的方式创建一个logging.jar文件。但是首先,您必须创建一个包含包信息的小文本文件。您可以为文件选择任何名称。清单 4-4 揭示了我对manifest.mf的选择。

清单 4-4。 manifest.mf包含包裹信息

Implementation-Title: Logging Implementation
Implementation-Vendor: Jeff Friesen
Implementation-Version: 1.0a
Specification-Title: Logging Specification
Specification-Vendor: Jeff Friesen
Specification-Version: 1.0
Sealed: true

images 注意确保在最后一行结束时按回车键(Sealed: true)。否则,您可能会在输出中看到Sealed: false,因为这个条目不会被 JDK 的jar工具存储在logging包中— jar有点古怪。

执行下面的命令行来创建一个 JAR 文件,该文件包括logging及其文件,其清单,一个名为MANIFEST.MF的特殊文件,存储了关于 JAR 文件内容的信息,包含了清单 4-4 的内容:

jar cfm logging.jar manifest.mf logging/*.class

这个命令行创建一个名为logging.jar的 JAR 文件(通过c [create]和f [file]选项)。它还将manifest.mf的内容(通过m【清单】选项)合并到MANIFEST.MF中,存储在包的META-INF目录中。

images 注意要了解关于 JAR 文件清单的更多信息,请阅读 JDK 文档“JAR 文件规范”页面的“JAR 清单”部分([download.oracle.com/javase/7/docs/technotes/guides/jar/jar.html#JAR Manifest](http://download.oracle.com/javase/7/docs/technotes/guides/jar/jar.html#JARManifest))。

假设jar工具没有显示错误信息,执行下面的面向 Windows 的命令行(或者适合您平台的命令行)来运行PackageInfo,并从logging包中提取包信息:

java -cp logging.jar;. PackageInfo logging 1.0 logging.File

这一次,您应该会看到以下输出:

Name: logging
Implementation title: Logging Implementation
Implementation vendor: Jeff Friesen
Implementation version: 1.0a
Specification title: Logging Specification
Specification vendor: Jeff Friesen
Specification version: 1.0
Sealed: true
Compatible with 1.0: true

原始类型包装类

java.lang包包括BooleanByteCharacterDoubleFloatIntegerLongShort。这些类被称为原始类型包装类,因为它们的实例将自己包装在原始类型的值周围。

images 注意原始类型包装类也被称为值类

Java 提供这八个基本类型包装类有两个原因:

  • 集合框架(在第五章中讨论)提供了只能存储对象的列表、集合和映射;它们不能存储原始值。您将基元值存储在基元类型包装类实例中,并将该实例存储在集合中。
  • 这些类提供了一个将有用的常量(如MAX_VALUEMIN_VALUE)和类方法(如IntegerparseInt()方法和CharacterisDigit()isLetter()toUpperCase()方法)与原始类型关联起来的好地方。

本节将向您介绍这些原始类型包装器类和一个名为Numberjava.lang类。

布尔值

Boolean是最小的原始类型包装类。这个类声明了三个常量,包括TRUEFALSE,它们表示预先创建的Boolean对象。它还声明了一对用于初始化Boolean对象的构造函数:

  • Boolean(boolean value)Boolean对象初始化为value
  • Boolean(String s)s的文本转换为 true 或 false 值,并将该值存储在Boolean对象中。

第二个构造函数将s的值与true进行比较。因为比较是不区分大小写的,所以这四个字母的任何大写/小写组合(比如trueTRUEtRue)都会导致 true 被存储在对象中。否则,构造函数在对象中存储 false。

images Boolean的构造函数用boolean booleanValue()补,返回包装后的布尔值。

Boolean还声明或覆盖以下方法:

  • int compareTo(Boolean b)将当前的Boolean对象与b进行比较,以确定它们的相对顺序。当当前对象包含与b相同的布尔值时,该方法返回 0;当当前对象包含 true 且b包含 false 时,该方法返回正值;当当前对象包含 false 且b包含 true 时,该方法返回负值。
  • boolean equals(Object o)将当前的Boolean对象与o进行比较,当o不为空,o的类型为Boolean,且两个对象包含相同的布尔值时,返回 true。
  • 当由name标识的系统属性(将在本章后面讨论)存在并且等于 true 时,static boolean getBoolean(String name)返回 true。
  • int hashCode()返回一个合适的散列码,允许Boolean对象用于基于散列的集合(在第五章中讨论)。
  • static boolean parseBoolean(String s)解析s,如果s等于"true""TRUE""True"或任何其他大写/小写组合,则返回 true。否则,此方法返回 false。(解析将一个字符序列分解成有意义的成分,称为记号。)
  • 当当前Boolean实例包含 true 时,String toString()返回"true";否则,该方法返回"false"
  • b包含 true 时,static String toString(boolean b)返回"true";否则,该方法返回"false"
  • b为真时static Boolean valueOf(boolean b)返回TRUE,当b为假时FALSE
  • s等于"true""TRUE""True"或这些字母的任何其他大写/小写组合时,static Boolean valueOf(String s)返回TRUE。否则,该方法返回FALSE

images 注意新加入Boolean类的人通常认为getBoolean()返回一个Boolean对象的真/假值。然而,getBoolean()返回一个基于布尔值的系统属性的值——我将在本章后面讨论系统属性。如果你需要返回一个Boolean对象的真/假值,请使用booleanValue()方法。

使用TRUEFALSE通常比创建Boolean对象更好。例如,假设您需要一个方法,当该方法的double参数为负时,该方法返回包含 true 的Boolean对象,当该参数为零或正时,该方法返回 false。您可以像下面的isNegative()方法一样声明您的方法:

Boolean isNegative(double d)
{
   return new Boolean(d < 0);
}

虽然这个方法很简洁,但它不必要地创建了一个Boolean对象。当频繁调用该方法时,会创建许多消耗堆空间的Boolean对象。当堆空间不足时,垃圾收集器会运行并降低应用的速度,从而影响性能。

下面的例子揭示了一种编码isNegative()的更好方法:

Boolean isNegative(double d)
{
   return (d < 0) ? Boolean.TRUE : Boolean.FALSE;
}

这个方法通过返回预先创建的TRUEFALSE对象来避免创建Boolean对象。

images 提示你应该努力创建尽可能少的对象。您的应用不仅内存占用更少,而且性能更好,因为垃圾收集器不会像以前那样频繁运行。

人物

Character是最大的原始类型包装类,包含许多常量、一个构造函数、许多方法和三个嵌套类(SubsetUnicodeBlockUnicodeScript)。

images 注意 Character的复杂性来源于 Java 对 Unicode ( [y](http://en.wikipedia.org/wiki/Unicode))的支持。为了简洁起见,我忽略了大部分Character与 Unicode 相关的复杂性,这超出了本章的范围。

Character声明了一个单独的Character(char value)构造函数,用来将一个Character对象初始化为value。这个构造函数由char charValue()补充,它返回包装的字符值。

当您开始编写应用时,您可能会编写像ch >= '0' && ch <= '9'(测试ch以查看它是否包含一个数字)和ch >= 'A' && ch <= 'Z'(测试ch以查看它是否包含一个大写字母)这样的表达式。您应该避免这样做,原因有三:

  • 在表达式中引入 bug 太容易了。例如,ch > '0' && ch <= '9'引入了一个微妙的 bug,在比较中没有包括'0'
  • 这些表达式不能很好地描述他们正在测试的东西。
  • 表达式偏向于拉丁数字(0-9)和字母(a-z 和 A-Z)。它们不考虑在其他语言中有效的数字和字母。例如,'\u0beb'是代表泰米尔语中一个数字的字符文字。

Character声明了几个比较和转换类方法来解决这些问题。这些方法包括以下内容:

  • ch包含一个数字(通常是 0 到 9,但也可以是其他字母中的数字)时,static boolean isDigit(char ch)返回 true。
  • ch包含一个字母(通常是 a-z 或 A-Z,但也包括其他字母表中的字母)时,static boolean isLetter(char ch)返回 true。
  • c h 包含一个字母或数字(通常是 a-z、A-Z 或 0-9;也包括其他字母表中的字母或数字)。
  • ch包含小写字母时,static boolean isLowerCase(char ch)返回 true。
  • ch包含大写字母时,static boolean isUpperCase(char ch)返回 true。
  • ch包含空白字符(通常是空格、水平制表符、回车符或换行符)时,static boolean isWhitespace(char ch)返回 true。
  • static char toLowerCase(char ch)返回ch的大写字母的小写等价物;否则,这个方法返回ch的值。
  • static char toUpperCase(char ch)返回ch小写字母的大写等价物;否则,这个方法返回ch的值。

例如,isDigit(ch)ch >= '0' && ch <= '9'更好,因为它避免了错误的来源,可读性更好,并且对于非拉丁数字(例如,'\u0beb')以及拉丁数字都返回 true。

浮动双精度

FloatDouble分别在FloatDouble对象中存储浮点和双精度浮点值。这些类声明下列常量:

  • MAX_VALUE表示可以用floatdouble表示的最大值。
  • MIN_VALUE表示可以用floatdouble表示的最小值。
  • NaN代表0.0F/0.0Ffloat0.0/0.0double
  • NEGATIVE_INFINITY用一个floatdouble来表示-无穷大。
  • POSITIVE_INFINITY将+无穷大表示为floatdouble

FloatDouble还声明了以下用于初始化其对象的构造函数:

  • Float(float value)Float对象初始化为value
  • Float(double value)Float对象初始化为valuefloat等价物。
  • Float(String s)s的文本转换为浮点值,并将该值存储在Float对象中。
  • Double(double value)Double对象初始化为value
  • Double(String s)s的文本转换为双精度浮点值,并将该值存储在Double对象中。

Float的构造函数由float floatValue()补充,后者返回包装的浮点值。类似地,Double的构造函数由double doubleValue()补充,它返回包装的双精度浮点值。

Float声明了几个实用方法和floatValue()。这些方法包括以下内容:

  • static int floatToIntBits(float value)value转换为 32 位整数。
  • f的值为+无穷大或-无穷大时,static boolean isInfinite(float f)返回 true。当当前Float对象的值为+infinity 或-infinity 时,相关的boolean isInfinite()方法返回 true。
  • f的值为 NaN 时,static boolean isNaN(float f)返回 true。当当前Float对象的值为 NaN 时,相关的boolean isNaN()方法返回 true。
  • static float parseFloat(String s)解析s,返回浮点值的s文本表示的浮点等价物,或者当该表示无效时抛出NumberFormatException(例如,包含字母)。

Double声明了几个实用方法和doubleValue()。这些方法包括以下内容:

  • static long doubleToLongBits(double value)value转换为长整数。
  • d的值为+无穷大或-无穷大时,static boolean isInfinite(double d)返回 true。当当前Double对象的值为+infinity 或-infinity 时,相关的boolean isInfinite()方法返回 true。
  • d的值为 NaN 时,static boolean isNaN(double d)返回 true。当当前Double对象的值为 NaN 时,相关的boolean isNaN()方法返回 true。
  • static double parseDouble(String s)解析s,返回与s的双精度浮点值的文本表示等价的双精度浮点值,或者当这个表示无效时抛出NumberFormatException

floatToIntBits()doubleToIntBits()方法在equals()hashCode()方法的实现中使用,它们必须考虑floatdouble字段。floatToIntBits()doubleToIntBits()允许equals()hashCode()正确响应以下情况:

  • f1f2包含Float.NaN(或者d1d2包含Double.NaN)时和equals()必须返回 true。如果equals()以类似于f1.floatValue() == f2.floatValue()(或d1.doubleValue() == d2.doubleValue())的方式实现,这个方法将返回 false,因为 NaN 不等于任何东西,包括它本身。
  • f1包含+0.0 而f2包含-0.0 时equals()必须返回 false(反之亦然),或者d1包含+0.0 而d2包含-0.0 时(反之亦然)。如果equals()以类似于f1.floatValue() == f2.floatValue()(或d1.doubleValue() == d2.doubleValue())的方式实现,这个方法将返回 true,因为+0.0 == -0.0返回 true。

这些需求是基于散列的集合(在第五章的中讨论)正常工作所必需的。清单 4-5 展示了它们如何影响FloatDoubleequals()方法:

清单 4-5演示 NaN 上下文中的Float'equals()方法和+/-0.0 上下文中的Doubleequals()方法

class FloatDoubleDemo
{
   public static void main(String[] args)
   {
      Float f1 = new Float(Float.NaN);
      System.out.println(f1.floatValue());
      Float f2 = new Float(Float.NaN);
      System.out.println(f2.floatValue());
      System.out.println(f1.equals(f2));
      System.out.println(Float.NaN == Float.NaN);
      System.out.println();
      Double d1 = new Double(+0.0);
      System.out.println(d1.doubleValue());
      Double d2 = new Double(-0.0);
      System.out.println(d2.doubleValue());
      System.out.println(d1.equals(d2));
      System.out.println(+0.0 == -0.0);
   }
}

编译清单 4-5 ( javac FloatDoubleDemo.java)并运行这个应用(java FloatDoubleDemo)。下面的输出证明了Floatequals()方法正确处理了 NaN,Doubleequals()方法正确处理了+/-0.0:

NaN
NaN
true
false

0.0
-0.0
false
true

images 提示如果您想测试floatdouble值是否等于+无穷大或-无穷大(但不是两者都等于),请不要使用isInfinite()。相反,通过==将该值与NEGATIVE_INFINITYPOSITIVE_INFINITY进行比较。比如f == Float.NEGATIVE_INFINITY

你会发现parseFloat()parseDouble()在很多情况下都很有用。例如,清单 4-6 使用parseDouble()将命令行参数解析成double

清单 4-6。将命令行参数解析成双精度浮点值

class Calc
{
   public static void main(String[] args)
   {
      if (args.length != 3)
      {
         System.err.println("usage: java Calc value1 op value2");
         System.err.println("op is one of +, -, *, or /");
         return;
      }
      try
      {
         double value1 = Double.parseDouble(args[0]);
         double value2 = Double.parseDouble(args[2]);
         if (args[1].equals("+"))
            System.out.println(value1+value2);
         else
         if (args[1].equals("-"))
            System.out.println(value1-value2);
         else
         if (args[1].equals("*"))
            System.out.println(value1*value2);
         else
         if (args[1].equals("/"))
            System.out.println(value1/value2);
         else
            System.err.println("invalid operator: "+args[1]);
      }
      catch (NumberFormatException nfe)
      {
         System.err.println("Bad number format: "+nfe.getMessage());
      }
   }
}

指定java Calc 10E+3 + 66.0来试用Calc应用。这个应用通过输出10066.0来响应。如果您指定了java Calc 10E+3 + A,您会看到Bad number format: For input string: "A"作为输出,这是对第二个parseDouble()方法调用抛出一个NumberFormatException对象的响应。

尽管NumberFormatException描述了一个未检查的异常,并且尽管未检查的异常通常不会被处理,因为它们代表编码错误,但是NumberFormatException在这个例子中不符合这个模式。该异常不是由编码错误引起的;它源于有人向应用传递了非法的数字参数,这是无法通过正确的编码来避免的。也许NumberFormatException应该被实现为一个检查异常类型。

整数、长整型、短整型和字节型

IntegerLongShortByte分别在IntegerLongShortByte对象中存储 32 位、64 位、16 位和 8 位的整数值。

每个类都声明了MAX_VALUEMIN_VALUE常量,它们标识了最大值和最小值,这些值可以由相关的原语类型来表示。这些类还声明了以下用于初始化其对象的构造函数:

  • Integer(int value)Integer对象初始化为value
  • Integer(String s)s的文本转换为 32 位整数值,并将该值存储在Integer对象中。
  • Long(long value)Long对象初始化为value
  • Long(String s)s的文本转换为 64 位整数值,并将该值存储在Long对象中。
  • Short(short value)Short对象初始化为value
  • Short(String s)s的文本转换为 16 位整数值,并将该值存储在Short对象中。
  • Byte(byte value)Byte对象初始化为value
  • Byte(String s)s的文本转换为 8 位整数值,并将该值存储在Byte对象中。

Integer的构造函数由int intValue()补充,Long的构造函数由long longValue()补充,Short的构造函数由short shortValue()补充,Byte的构造函数由byte byteValue()补充。这些方法返回包装的整数。

这些类声明了各种有用的面向整数的方法。例如,Integer声明了以下实用方法,用于根据特定的表示形式(二进制、十六进制、八进制和十进制)将 32 位整数转换为java.lang.String实例:

  • static String toBinaryString(int i)返回一个包含i的二进制表示的String对象。例如,Integer.toBinaryString(255)返回一个包含 11111111 的String对象。
  • static String toHexString(int i)返回一个包含i的十六进制表示的String对象。例如,Integer.toHexString(255)返回一个包含 ff 的String对象。
  • static String toOctalString(int i)返回一个包含i的八进制表示的String对象。例如,toOctalString(64)返回一个包含 100 的String对象。
  • static String toString(int i)返回一个包含i的十进制表示的String对象。例如,toString(255)返回一个包含 255 的String对象。

在二进制字符串前面加上零通常很方便,这样就可以在列中对齐多个二进制字符串。例如,您可能希望创建一个显示以下对齐输出的应用:

11110001
+
00000111
--------
11111000

不幸的是,toBinaryString()并没有让你完成这个任务。例如,Integer.toBinaryString(7)返回一个包含 111 的String对象,而不是 00000111。清单 4-7 的toAlignedBinaryString()方法解决了这个疏忽。

清单 4-7。对齐二进制字符串

class AlignBinary
{
   public static void main(String[] args)
   {
      System.out.println(toAlignedBinaryString(7, 8));
      System.out.println(toAlignedBinaryString(255, 16));
      System.out.println(toAlignedBinaryString(255, 7));
   }
   static String toAlignedBinaryString(int i, int numBits)
   {
      String result = Integer.toBinaryString(i);
      if (result.length() > numBits)
         return null; // cannot fit result into numBits columns
      int numLeadingZeros = numBits-result.length();
      String zerosPrefix = "";
      for (int j = 0; j < numLeadingZeros; j++)
         zerosPrefix += "0";
      return zerosPrefix+result;
   }
}

toAlignedBinaryString()方法有两个参数:第一个参数指定了要转换成二进制字符串的 32 位整数,第二个参数指定了字符串所包含的位列数。

在调用toBinaryString()返回i的不带前导零的等价二进制字符串后,toAlignedBinaryString()验证该字符串的数字是否能符合numBits指定的位列数。如果它们不匹配,此方法将返回 null。(在本章的后面,你会学到length()和其他String方法。)

继续,toAlignedBinaryString()计算前置到result的前导"0"的数量,然后使用 for 循环创建一串前导零。此方法通过返回结果字符串前面的前导零字符串来结束。

虽然在一个循环中使用带有赋值操作符(+=)的复合字符串串联来构建一个字符串看起来没问题,但这是非常低效的,因为中间的String对象被创建并被丢弃。然而,我使用了这个低效的代码,这样我就可以将它与我在本章后面介绍的更高效的代码进行对比。

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

00000111
0000000011111111
null

数字

每个FloatDoubleIntegerLongShortByte都提供了其他类的*x*Value()方法和自己的*x*Value()方法。例如,Float提供doubleValue()intValue()longValue()shortValue()byteValue()以及floatValue()

这六个方法都是Number的成员,?? 是FloatDoubleIntegerShortByte的抽象超类,而NumberfloatValue()doubleValue()intValue()longValue()方法是抽象的。Number也是java.math.BigDecimaljava.math.BigInteger的超类(本章稍后讨论),以及一对并发相关的类(其中一个类在第六章中介绍)。

Number的存在是为了简化对Number子类对象集合的迭代。例如,您可以声明一个java.util.List<Number>类型的变量,并将其初始化为一个java.util.ArrayList<Number>(或简称为ArrayList<>)的实例。然后,您可以在集合中存储一个混合的Number子类对象,并通过多态地调用一个子类方法来迭代这个集合。

参考

第二章向您介绍了垃圾收集,在这里您了解了当一个对象不再被引用时,垃圾收集器会从堆中移除该对象。你很快就会发现,这种说法并不完全正确。

第二章还向您介绍了java.lang.Objectfinalize()方法,在这里您了解到垃圾收集器在从堆中移除对象之前调用这个方法。finalize()方法给对象一个执行清理的机会。

本节继续第二章的内容,向您介绍 Java 的参考 API。在熟悉了一些基本术语之后,它将向您介绍 API 的ReferenceReferenceQueue类,然后是 API 的SoftReferenceWeakReferencePhantomReference类。这些类让应用以有限的方式与垃圾收集器交互。

images 除了这一节,你还会发现 Brian Goetz 的《Java 理论与实践:用软引用堵塞内存泄漏》([www.ibm.com/developerworks/java/library/j-jtp01246/index.html](http://www.ibm.com/developerworks/java/library/j-jtp01246/index.html))和《Java 理论与实践:用弱引用堵塞内存泄漏》([www.ibm.com/developerworks/java/library/j-jtp11225/index.html](http://www.ibm.com/developerworks/java/library/j-jtp11225/index.html))教程有助于理解参考 API。

基本术语

当一个应用运行时,它的执行揭示了一个根引用集,一个当前存在的局部变量、参数、类字段和实例字段的集合,并且包含(可能为空)对对象的引用。这个根集随着应用的运行而变化。例如,参数在方法返回后消失。

许多垃圾收集器在运行时都会识别这个根集。他们使用根集来确定一个对象是可达(引用,也称为)还是不可达(未引用)。垃圾收集器无法收集可到达的对象。相反,它只能收集从根引用集开始无法到达的对象。

images 注意可达对象包括从根集变量间接可达的对象,这意味着通过可从那些变量直接可达的活对象可达的对象。任何根集变量的任何路径都无法到达的对象都有资格进行垃圾收集。

从 Java 1.2 开始,可达对象被分为强可达、软可达、弱可达和幻影可达。与强可达对象不同,软可达对象、弱可达对象和幻像可达对象可以被垃圾收集。

从最强到最弱,可达性的不同级别反映了对象的生命周期。它们的定义如下:

  • 一个对象是强可达的,如果它可以从某个线程到达而不遍历任何Reference对象。一个新创建的对象(比如由Double d = new Double(1.0);中的d引用的对象)对于创建它的线程来说是强可及的。(我将在本章后面讨论线程。)
  • 一个对象是软可达的,如果它不是强可达的,但是可以通过遍历一个软引用(一个对该对象的引用,其中该引用存储在一个SoftReference对象中)。对该对象的最强引用是软引用。当对一个软可及对象的软引用被清除时,该对象变得有资格终结(在第二章中讨论)。
  • 如果一个对象既不是强可达的也不是软可达的,那么它就是弱可达的(??),但是可以通过遍历一个弱引用(对该对象的引用,该引用存储在一个对象中)。对此对象的最强引用是弱引用。当对弱可达对象的弱引用被清除时,该对象就有资格终结。(除了垃圾收集器更渴望清理弱可达对象之外,弱引用完全类似于软引用。)
  • 一个对象是幻影可达的如果它既不是强可达的、软可达的,也不是弱可达的,那么它已经被终结,并且它被某个幻影引用(一个对对象的引用,其中引用存储在一个PhantomReference对象中)。对此对象的最强引用是幻影引用。
  • 最后,一个对象是不可到达的,因此在下一个垃圾收集周期,当它不能以上述任何方式到达时,有资格从内存中移除。

其引用存储在SoftReferenceWeakReferencePhantomReference对象中的对象被称为引用对象

引用和引用队列

参考 API 由位于java.lang.ref包中的五个类组成。这个方案的核心是ReferenceReferenceQueue

Reference是这个包的具体SoftReferenceWeakReferencePhantomReference子类的抽象超类。

ReferenceQueue是一个具体的类,它的实例描述了队列数据结构。当您将一个ReferenceQueue实例与一个Reference子类对象(简称为Reference对象)相关联时,当Reference对象的封装引用所引用的对象成为垃圾时,该对象将被添加到队列中。

images 注意通过将ReferenceQueue对象传递给一个适当的Reference子类构造函数,你可以将一个ReferenceQueue对象与一个Reference对象相关联。

Reference被声明为泛型类型Reference<T>,其中T标识引用对象的类型。该类提供了以下方法:

  • void clear()将空值赋给存储的引用;调用此方法的Reference对象没有入队(插入)到其关联的引用队列中(如果有关联的引用队列)。(垃圾收集器直接清除引用;它不叫clear()。相反,此方法由应用调用。)
  • boolean enqueue()将调用该方法的Reference对象添加到关联的引用队列中。当这个Reference对象进入队列时,这个方法返回 true 否则,该方法返回 false—这个Reference对象在创建时已经入队或者没有与队列相关联。(垃圾收集器直接将Reference对象入队;它不叫enqueue()。相反,此方法由应用调用。)
  • T get()返回这个Reference对象的存储引用。当应用或垃圾收集器清除了存储的引用时,返回值为 null。
  • 当这个Reference对象已经被应用或垃圾收集器排队时,boolean isEnqueued()返回 true。否则,该方法返回 false—这个Reference对象在创建时没有与队列相关联。

images Reference也声明构造函数。因为这些构造函数是包私有的,所以只有java.lang.ref包中的类可以继承Reference。这个限制是必要的,因为Reference的子类的实例必须与垃圾收集器紧密合作。

ReferenceQueue被声明为泛型类型ReferenceQueue<T>,其中T标识引用对象的类型。该类声明了以下构造函数和方法:

  • ReferenceQueue()初始化一个新的ReferenceQueue实例。
  • Reference<? extends T> poll()轮询该队列以检查可用的Reference对象。如果有可用的对象,则从队列中移除并返回该对象。否则,此方法立即返回一个空值。
  • Reference<? extends T> remove()从队列中移除下一个Reference对象并返回该对象。这个方法无限期地等待一个Reference对象变得可用,并在等待被中断时抛出java.lang.InterruptedException
  • Reference<? extends T> remove(long timeout)从队列中移除下一个Reference对象并返回该对象。这个方法一直等到一个Reference对象变得可用或者已经过了timeout毫秒——将 0 传递给timeout会导致该方法无限期等待。如果timeout的值过期,该方法返回 null。当timeout的值为负时,这个方法抛出java.lang.IllegalArgumentException,或者当这个等待被中断时,抛出InterruptedException

软参考

SoftReference类描述了一个Reference对象,其 referent 是软可及的。除了继承Reference的方法和覆盖get()之外,这个泛型类还提供了以下用于初始化SoftReference对象的构造函数:

  • SoftReference(T r)封装了r的引用。SoftReference对象表现为对r的软引用。没有ReferenceQueue对象与此SoftReference对象相关联。
  • SoftReference(T r, ReferenceQueue<? super T> q)封装了r的引用。SoftReference对象表现为对r的软引用。由q标识的ReferenceQueue对象与该SoftReference对象相关联。将null传递到q表示没有队列的软引用。

SoftReference对于实现创建(例如数据库连接)时间开销大和/或占用大量堆空间的对象(例如大型图像)的缓存非常有用。图像缓存将图像保存在内存中(因为从磁盘加载图像需要时间),并确保重复的(可能非常大的)图像不会存储在内存中。

图像缓存包含对已经在内存中的图像对象的引用。如果这些引用是强有力的,这些图像将会留在记忆中。然后,您需要找出哪些图像不再需要,并将它们从内存中删除,以便可以对它们进行垃圾收集。

必须手动删除图像重复了垃圾收集器的工作。但是,如果您将对图像对象的引用包装在SoftReference对象中,垃圾收集器将确定何时移除这些对象(通常是在堆内存不足时)并代表您执行移除。

清单 4-8 展示了如何使用SoftReference来缓存图像。

清单 4-8。缓存图像

import java.lang.ref.SoftReference;

class Image
{
   private byte[] image;
   private Image(String name)
   {
      image = new byte[1024*1024*100];
   }
   static Image getImage(String name)
   {
      return new Image(name);
   }
}
class ImageCache
{
   public static void main(String[] args)
   {
      Image image = Image.getImage("large.png");
      System.out.println("caching image");
      SoftReference<Image> cache = new SoftReference<>(image);
      image = null;
      byte[] b = new byte[1024];
      while (cache.get() != null)
      {
         System.out.println("image is still cached");
         b = new byte[b.length*10];
      }
      System.out.println("image is no longer cached");
      b = null;
      System.out.println("reloading and recaching image");
      cache = new SoftReference<>(Image.getImage("large.png"));
      int counter = 0;
      while (cache.get() != null && ++counter != 7)
         System.out.println("image is still cached");
   }
}

清单 4-8 声明了一个模拟加载大图像的Image类和一个演示基于SoftReferenceImage对象缓存的ImageCache类。

main()方法首先通过调用getImage()类方法创建一个Image实例;实例的私有image数组占用 100MB 内存。

main()接下来创建一个SoftReference对象,该对象被初始化为一个Image对象的引用,并通过将null赋值给image来清除对Image对象的强引用。如果这个强引用没有被删除,Image对象将一直被缓存,应用很可能会耗尽内存。

在创建了一个用于演示SoftReference的字节数组后,main()进入应用的主循环,只要cache.get()返回一个非空引用(Image对象仍在缓存中),主循环就会一直循环下去。对于每次循环迭代,main()输出一条消息,表明Image对象仍然被缓存,并将字节数组的大小加倍。

在某些时候,数组加倍会耗尽堆空间。然而,在抛出java.lang.OutOfMemoryError类的实例之前,Java 虚拟机(JVM)将试图通过清除SoftReference对象的Image引用,并从堆中移除Image对象来获得足够的内存。

下一次循环迭代将通过发现get()返回 null 来检测这种情况。循环结束,main()输出一个合适的消息,确认Image对象不再被缓存。

main()现在将null分配给b,以确保有足够的内存来重新加载大图像(通过getImage(),并再次将其存储在基于SoftReference的缓存中。

最后,main()进入一个有限循环,以证明重新加载的Image对象仍然在缓存中。

编译清单 4-8 ( javac ImageCache.java)并运行应用(java ImageCache)。您应该会发现类似如下所示的输出:

caching image
image is still cached
image is still cached
image is still cached
image is still cached
image is still cached
image is no longer cached
reloading and recaching image
image is still cached
image is still cached
image is still cached
image is still cached
image is still cached
image is still cached

软弱的指称

WeakReference类描述了一个Reference对象,它的 referent 是弱可达的。除了继承Reference的方法,这个通用类还提供了以下用于初始化WeakReference对象的构造函数:

  • WeakReference(T r)封装了r的引用。WeakReference对象表现为对r的弱引用。没有ReferenceQueue对象与此WeakReference对象相关联。
  • WeakReference(T r, ReferenceQueue<? super T> q)封装了r的引用。WeakReference对象表现为对r的弱引用。由q标识的ReferenceQueue对象与该WeakReference对象相关联。将null传递到q表示没有队列的弱引用。

WeakReference有助于防止与 hashmaps 相关的内存泄漏。当您不断地向 hashmap 添加对象而从不移除它们时,就会发生内存泄漏。对象保留在内存中,因为散列表存储了对它们的强引用。

理想情况下,只有当应用中的其他地方强烈引用这些对象时,它们才应该保留在内存中。当一个对象的最后一个强引用(除了 hashmap 强引用)消失时,这个对象应该被垃圾回收。

这种情况可以通过存储对 hashmap 条目的弱引用来弥补,这样当不存在对它们的键的强引用时,它们就会被丢弃。Java 的java.util.WeakHashMap类(在第五章的中讨论过)完成了这项任务,它的私有Entry静态成员类扩展了WeakReference

images 注意引用队列在WeakReference中比在SoftReference中更有用。在WeakHashMap的上下文中,这些队列提供已经被移除的弱引用键的通知。WeakHashMap中的代码使用队列提供的信息删除所有不再有有效键的 hashmap 条目,以便与这些无效键相关联的值对象可以被垃圾收集。然而,与SoftReference相关的队列可以提醒应用堆空间开始变少。

幻影参照

PhantomReference类描述了一个Reference对象,其 referent 是幻影可达的。除了继承Reference的方法和覆盖get()之外,这个泛型类还提供了一个用于初始化PhantomReference对象的构造函数:

  • PhantomReference(T r, ReferenceQueue<? super T> q)封装了r的引用。PhantomReference对象表现为对r的幻影引用。由q标识的ReferenceQueue对象与该PhantomReference对象相关联。将null传递给q是没有意义的,因为get()被覆盖以返回 null,而PhantomReference对象将永远不会排队。

虽然您不能访问一个PhantomReference对象的 referent(它的get()方法返回 null),但是这个类是有用的,因为将PhantomReference对象入队表明 referent 已经完成,但是它的内存空间还没有被回收。该信号允许您在不使用finalize()方法的情况下执行清理。

finalize()方法是有问题的,因为垃圾收集器需要至少两个垃圾收集周期来确定覆盖finalize()的对象是否可以被垃圾收集。当第一个周期检测到对象符合垃圾收集条件时,它调用finalize()。因为这个方法可能会执行复活(见第二章),这使得不可到达的对象可到达,所以需要第二个垃圾收集周期来确定复活是否已经发生。这个额外的周期减慢了垃圾收集的速度。

如果finalize()没有被覆盖,垃圾收集器就不需要调用那个方法,并认为对象已经完成。因此,垃圾收集器只需要一个周期。

虽然您不能通过finalize()执行清理,但是您仍然可以通过PhantomReference执行清理。因为没有办法访问 referent ( get()返回 null),所以复活不可能发生。

清单 4-9 展示了如何使用PhantomReference来检测一个大对象的终结。

清单 4-9。检测大对象的终结

import java.lang.ref.PhantomReference; import java.lang.ref.ReferenceQueue;

class LargeObject
{
   private byte[] memory = new byte[1024*1024*50]; // 50 megabytes
}
class LargeObjectDemo
{
   public static void main(String[] args)
   {
      ReferenceQueue<LargeObject> rq;
      rq = new ReferenceQueue<LargeObject>();
      PhantomReference<LargeObject> pr;
      pr = new PhantomReference<LargeObject>(new LargeObject(), rq);
      byte[] b = new byte[1024];
      while (rq.poll() == null)
      {
         System.out.println("waiting for large object to be finalized");
         b = new byte[b.length*10];
      }
      System.out.println("large object finalized");
      System.out.println("pr.get() returns "+pr.get());
   }
}

清单 4-9 声明了一个LargeObject类,它的私有memory数组占用了 50MB。如果您的 JVM 在运行LargeObject时抛出OutOfMemoryError,您可能需要减小数组的大小。

main()方法首先创建一个描述队列的ReferenceQueue对象,最初包含一个LargeObject引用的PhantomReference对象将在该队列中排队。

main()接下来创建PhantomReference对象,将对新创建的LargeObject对象的引用和对先前创建的ReferenceQueue对象的引用传递给构造函数。

在创建一个用于演示PhantomReference的字节数组后,main()进入一个轮询循环。

轮询循环从调用poll()开始,以检测LargeObject对象的终结。只要该方法返回 null,这意味着LargeObject对象仍未终结,循环就输出一条消息,并将字节数组的大小加倍。

在某个时刻,堆空间将耗尽,垃圾收集器将试图获得足够的内存,首先清除PhantomReference对象的LargeObject引用,并在将LargeObject对象从堆中移除之前完成该对象。然后PhantomReference对象排队到rq引用的ReferenceQueue上;poll()返回PhantomReference对象。

main()现在退出循环,输出一条确认大对象终结的消息,并输出pr.get()的返回值,该值为空,证明您不能访问PhantomReference对象的 referent。此时,可以执行与终结对象相关的任何其他清理操作(例如关闭在文件的构造函数中打开但没有关闭的文件)。

编译清单 4-9 并运行应用。您应该会看到类似如下所示的输出:

waiting for large object to be finalized waiting for large object to be finalized waiting for large object to be finalized waiting for large object to be finalized waiting for large object to be finalized large object finalized pr.get() returns null

images 关于PhantomReference更有用的例子,请看 Keith D Gregory 的“Java 引用对象”博客文章([www.kdgregory.com/index.php?page=java.refobj](http://www.kdgregory.com/index.php?page=java.refobj))。

反思

第二章提到了反射(也称为自省)作为运行时类型标识的第三种形式(RTTI)。Java 的反射 API 让应用了解加载的类、接口、枚举(一种类)和注释类型(一种接口)。它还允许应用动态加载类、实例化类、查找类的字段和方法、访问字段、调用方法,以及反射性地执行其他任务。

第三章展示了一个StubFinder应用,它使用反射 API 的一部分来加载一个类,并识别所有加载的类的公共方法,这些方法都用@Stub注释进行了注释。这个工具是使用反射是有益的一个例子。另一个例子是类浏览器,一个枚举类成员的工具。

images 告诫反思不可乱用。应用的性能会受到影响,因为使用反射比不使用反射执行操作需要更长的时间。此外,面向反射的代码可能更难阅读,缺少编译时类型检查可能会导致运行时失败。

java.lang包的Class类是反射 API 的入口点,其类型主要存储在java.lang.reflect包中。Class一般被声明为Class<T>,其中T标识由Class对象建模的类、接口、枚举或注释类型。当建模的类型未知时,T可以替换为?(如Class<?>)。

表 4-3 描述了Class的一些方法。

images

images

images

images

表 4-3 对forName()方法的描述揭示了一种获得Class对象的方法。这个方法加载、链接并初始化一个不在内存中的类或接口,并返回一个代表该类或接口的Class对象。清单 4-10 展示了forName()和此表中描述的其他方法。

清单 4-10。使用反射反编译一个类型

`import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;

class Decompiler
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java Decompiler classname");
         return;
      }
      try
      {
         decompileClass(Class.forName(args[0]), 0);
      }
      catch (ClassNotFoundException cnfe)
      {
         System.err.println("could not locate "+args[0]);
      }
   }
   static void decompileClass(Class clazz, int indentLevel)    {       indent(indentLevel*3);       System.out.print(Modifier.toString(clazz.getModifiers())+" ");       if (clazz.isEnum())          System.out.println("enum "+clazz.getName());       else       if (clazz.isInterface())       {          if (clazz.isAnnotation())             System.out.print("@");          System.out.println(clazz.getName());       }       else          System.out.println(clazz);       indent(indentLevel*3);       System.out.println("{");       Field[] fields = clazz.getDeclaredFields();       for (int i = 0; i < fields.length; i++)       {          indent(indentLevel*3);          System.out.println("   "+fields[i]);       }` `Constructor[] constructors = clazz.getDeclaredConstructors();       if (constructors.length != 0 && fields.length != 0)          System.out.println();       for (int i = 0; i < constructors.length; i++)       {          indent(indentLevel*3);          System.out.println("   "+constructors[i]);       }       Method[] methods = clazz.getDeclaredMethods();       if (methods.length != 0 &&           (fields.length != 0 || constructors.length != 0))          System.out.println();       for (int i = 0; i < methods.length; i++)       {          indent(indentLevel*3);          System.out.println("   "+methods[i]);       }       Method[] methodsAll = clazz.getMethods();       if (methodsAll.length != 0 &&           (fields.length != 0 || constructors.length != 0 ||            methods.length != 0))          System.out.println();       if (methodsAll.length != 0)       {          indent(indentLevel*3);          System.out.println("   ALL PUBLIC METHODS");          System.out.println();       }       for (int i = 0; i < methodsAll.length; i++)       {          indent(indentLevel*3);          System.out.println("   "+methodsAll[i]);       }       Class[] members = clazz.getDeclaredClasses();
      if (members.length != 0 && (fields.length != 0 ||
          constructors.length != 0 || methods.length != 0 ||
          methodsAll.length != 0))
         System.out.println();
      for (int i = 0; i < members.length; i++)
         if (clazz != members[i])
         {
            decompileClass(members[i], indentLevel+1);
            if (i != members.length-1)
               System.out.println();
         }
      indent(indentLevel*3);
      System.out.println("}");
   }
   static void indent(int numSpaces)
   {
      for (int i = 0; i < numSpaces; i++)
         System.out.print(' ');
   } }`

清单 4-10 将源代码呈现给一个反编译器工具,该工具使用反射来获取关于该工具的唯一命令行参数的信息,该参数必须是 Java 引用类型(如类)。反编译器让你输出类的字段、构造函数、方法和嵌套类型的类型和名称信息;它还允许您输出接口、枚举和注释类型的成员。

在验证一个命令行参数已经被传递给这个应用之后,main()调用forName()来尝试返回一个Class对象,该对象代表由这个参数标识的类或接口。如果成功,返回的对象的引用被传递给decompileClass(),后者反编译该类型。

forName()在找不到类的类文件时抛出被检查的ClassNotFoundException类的一个实例(可能类文件在执行应用之前被删除了)。当一个类的类文件格式错误时,它还抛出LinkageError,当一个类的静态初始化失败时,它抛出ExceptionInInitializerError

images 注意 ExceptionInInitializerError通常是由于类初始化器抛出未检查的异常而抛出的。例如,下面的FailedInitialization类中的类初始化器产生了ExceptionInInitializerError,因为someMethod()抛出了NullPointerException:

class FailedInitialization
{
   static
   {
      someMethod(null);
   }
   static void someMethod(String s)
   {
      int len = s.length(); // s contains null
      System.out.println(s+"'s length is "+len+" characters");
   }
   public static void main(String[] args)
   {
   }
}

大部分打印代码都是为了让输出看起来更好。例如,这段代码管理缩进,只允许输出一个换行符来分隔不同的部分;除非换行符前后出现内容,否则不输出换行符。

清单 4-10 是递归的,因为它为每个遇到的嵌套类型调用decompileClass()

编译清单 4-10 ( javac Decompiler.java)并使用java.lang.Boolean作为其唯一的命令行参数(java Decompiler java.lang.Boolean)运行该应用。您将看到以下输出:

public final class java.lang.Boolean
{
   public static final java.lang.Boolean java.lang.Boolean.TRUE
   public static final java.lang.Boolean java.lang.Boolean.FALSE
   public static final java.lang.Class java.lang.Boolean.TYPE
   private final boolean java.lang.Boolean.value
   private static final long java.lang.Boolean.serialVersionUID

   public java.lang.Boolean(java.lang.String)
   public java.lang.Boolean(boolean)

   public int java.lang.Boolean.hashCode()
   public boolean java.lang.Boolean.equals(java.lang.Object)
   public java.lang.String java.lang.Boolean.toString()
   public static java.lang.String java.lang.Boolean.toString(boolean)
   public static int java.lang.Boolean.compare(boolean,boolean)
   public int java.lang.Boolean.compareTo(java.lang.Object)
   public int java.lang.Boolean.compareTo(java.lang.Boolean)
   public static java.lang.Boolean java.lang.Boolean.valueOf(boolean)
   public static java.lang.Boolean java.lang.Boolean.valueOf(java.lang.String)
   public boolean java.lang.Boolean.booleanValue()
   public static boolean java.lang.Boolean.getBoolean(java.lang.String)
   public static boolean java.lang.Boolean.parseBoolean(java.lang.String)
   private static boolean java.lang.Boolean.toBoolean(java.lang.String)

   ALL PUBLIC METHODS

   public int java.lang.Boolean.hashCode()
   public boolean java.lang.Boolean.equals(java.lang.Object)
   public java.lang.String java.lang.Boolean.toString()
   public static java.lang.String java.lang.Boolean.toString(boolean)
   public static int java.lang.Boolean.compare(boolean,boolean)
   public int java.lang.Boolean.compareTo(java.lang.Object)
   public int java.lang.Boolean.compareTo(java.lang.Boolean)
   public static java.lang.Boolean java.lang.Boolean.valueOf(boolean)
   public static java.lang.Boolean java.lang.Boolean.valueOf(java.lang.String)
   public boolean java.lang.Boolean.booleanValue()
   public static boolean java.lang.Boolean.getBoolean(java.lang.String)
   public static boolean java.lang.Boolean.parseBoolean(java.lang.String)
   public final native java.lang.Class java.lang.Object.getClass()
   public final native void java.lang.Object.notify()
   public final native void java.lang.Object.notifyAll()
   public final void java.lang.Object.wait(long,int) throws![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) java.lang.InterruptedException
   public final void java.lang.Object.wait() throws java.lang.InterruptedException
   public final native void java.lang.Object.wait(long) throws![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) java.lang.InterruptedException
}

输出揭示了调用getDeclaredMethods()getMethods()的区别。例如,与getDeclaredMethods()相关的输出包括私有的toBoolean()方法。同样,与getMethods()相关的输出包括没有被Boolean覆盖的Object方法;getClass()就是一个例子。

在清单 4-10 中没有展示的表 4-3 的方法之一是newInstance(),它对于实例化一个动态加载的类是有用的,只要这个类有一个无参数的构造函数。

假设您计划创建一个查看器应用,让用户查看不同类型的文件。例如,查看器可以查看反汇编的 Windows EXE 文件的指令序列、PNG 文件的图形内容或某些其他文件的内容。此外,用户可以选择以信息方式(描述性标签和内容;例如,EXE HEADER: MZ),或者作为十六进制值的表。

查看器应用开始时只有几个查看器,但是您计划随着时间的推移添加更多的查看器。您不希望将查看器源代码与应用源代码集成在一起,因为每次添加新的查看器(例如,允许您查看 Java 类文件内容的查看器)时,您都必须重新编译应用及其所有查看器。

相反,您可以在一个单独的项目中创建这些查看器,并且只分发它们的类文件。此外,设计应用以在应用开始运行时枚举其当前可访问的查看器(查看器可能存储在一个 JAR 文件中),并将该列表呈现给用户。当用户从这个列表中选择一个特定的查看器时,应用加载查看器的类文件,并通过它的Class对象实例化这个类。然后,应用可以调用该对象的方法。

清单 4-11 展示了所有查看器类都必须扩展的Viewer超类。

清单 4-11。抽象出一个观者

abstract class Viewer
{
   enum ViewMode { NORMAL, INFO, HEX };
   abstract void view(byte[] content, ViewMode vm);
}

Viewer声明一个枚举来描述三种查看模式。它还声明了一个view()方法,该方法根据其vm参数指定的查看器模式显示其字节数组参数的内容。

清单 4-12 展示了一个用于查看 EXE 文件内容的Viewer子类。

清单 4-12。用于查看 EXE 内容的查看器

class ViewerEXE extends Viewer {    @Override    void view(byte[] content, ViewMode vm)    {       switch (vm)       {          case NORMAL:             System.out.println("outputting EXE content normally");             break;          case INFO:             System.out.println("outputting EXE content informationally");             break;          case HEX:             System.out.println("outputting EXE content in hexadecimal");       }    } }

ViewerEXEview()方法演示了如何使用 switch 语句打开一个枚举常量。为了简洁起见,我将这个方法限制为将消息打印到标准输出。此外,我没有给出相应的ViewPNG类,它有类似的结构。

清单 4-13 展示了一个应用,它动态加载ViewerEXEViewerPNG,通过newInstance()实例化加载的类,并调用view()方法。

清单 4-13。加载、实例化和使用Viewer子类

class ViewerDemo
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage  : java ViewerDemo filetype");
         System.err.println("example: java ViewerDemo EXE");
         return;
      }
      try
      {
         Class<?> clazz = Class.forName("Viewer"+args[0]);
         Viewer viewer = (Viewer) clazz.newInstance();
         viewer.view(null, Viewer.ViewMode.HEX);
      }
      catch (ClassNotFoundException cnfe)
      {
         System.err.println("Class not found: "+cnfe.getMessage());
      }
      catch (IllegalAccessException iae)
      {
         System.err.println("Illegal access: "+iae.getMessage());
      }
      catch (InstantiationException ie)
      {
         System.err.println("Unable to instantiate loaded class");
      }
   }
}

假设您已经编译了所有的源文件(例如javac *.java,执行java ViewerDemo EXE。您应该观察到以下输出:

outputting EXE content in hexadecimal

如果您要执行java ViewerDemo PNG,您应该会看到类似的输出。

假设你试图通过java ViewerDemo ""加载并实例化抽象的Viewer类。尽管这个类会被加载,newInstance()会抛出一个InstantiationException类的实例,您会看到下面的输出:

Unable to instantiate loaded class

表 4-3 对getAnnotations()getDeclaredAnnotations()方法的描述揭示了每个方法返回一个Annotation数组,一个位于java.lang.annotation包中的接口。AnnotationOverrideSuppressWarnings和所有其他注释类型的超级界面。

表 4-3 的方法描述也参考了ConstructorFieldMethod。这些类的实例代表一个类的构造函数和一个类或接口的字段和方法。

Constructor代表一个构造函数,一般声明为Constructor<T>,其中T标识声明了Constructor所代表的构造函数的类。Constructor声明各种方法,包括以下方法:

  • Annotation[] getDeclaredAnnotations()返回在构造函数上声明的所有注释的数组。当没有注释时,返回的数组长度为零。
  • Class<T> getDeclaringClass()返回一个Class对象,代表声明构造函数的类。
  • Class[]<?> getExceptionTypes()返回一个由Class对象组成的数组,这些对象表示构造函数的 throws 子句中列出的异常类型。当没有 throws 子句时,返回的数组长度为零。
  • String getName()返回构造函数的名称。
  • Class[]<?> getParameterTypes()返回代表构造函数参数的Class对象的数组。当构造函数未声明参数时,返回的数组长度为零。

images 提示如果你想通过带参数的构造函数实例化一个类,你不能使用ClassnewInstance()方法。相反,你必须使用ConstructorT newInstance(Object... initargs)方法来执行这个任务。与ClassnewInstance()方法不同,它绕过了编译时异常检查,否则将由编译器执行,ConstructornewInstance()方法通过将构造函数抛出的任何异常包装在java.lang.reflect.InvocationTargetException类的实例中来避免这个问题。

Field表示一个字段并声明各种方法,包括以下 getter 方法:

  • Object get(Object object)返回指定object的字段值。
  • boolean getBoolean(Object object)返回指定object的布尔字段的值。
  • byte getByte(Object object)返回指定object的字节整数字段的值。
  • char getChar(Object object)返回指定object的字符字段值。
  • double getDouble(Object object)返回指定object的双精度浮点字段的值。
  • float getFloat(Object object)返回指定object的浮点字段值。
  • int getInt(Object object)返回指定object的整数字段的值。
  • long getLong(Object object)返回指定object的长整型字段的值。
  • short getShort(Object object)返回指定object的短整型字段的值。

get()返回任意类型字段的值。相比之下,其他列出的方法返回特定类型字段的值。当objectnull并且字段是实例字段时,这些方法抛出NullPointerException,当object不是声明底层字段的类或接口的实例(或者不是子类或接口实现者的实例)时抛出IllegalArgumentException,当底层字段不能被访问时抛出IllegalAccessException(例如,它是私有的)。

清单 4-14 展示了FieldgetInt(Object)方法及其对应的void setInt(Object obj, int i)方法。

清单 4-14。反射式获取和设置实例和类字段的值

`import java.lang.reflect.Field;

class X
{
   public int i = 10;
   public static final double PI = 3.14;
}
class FieldAccessDemo
{
   public static void main(String[] args)
   {
      try
      {
         Class<?> clazz = Class.forName("X");
         X x = (X) clazz.newInstance();
         Field f = clazz.getField("i");
         System.out.println(f.getInt(x)); // Output: 10
         f.setInt(x, 20);
         System.out.println(f.getInt(x)); // Output: 20
         f = clazz.getField("PI");
         System.out.println(f.getDouble(null)); // Output: 3.14
         f.setDouble(x, 20);
         System.out.println(f.getDouble(null)); // Never executed
      }
      catch (Exception e)
      {          System.err.println(e);
      }
   }
}`

清单 4-14 声明了类XFieldAccessDemo。为了方便起见,我将X的源代码和FieldAccessDemo的源代码放在了一起。但是,您可以想象这个源代码存储在一个单独的源文件中。

FieldAccessDemomain()方法首先尝试加载X,然后尝试通过newInstance()实例化这个类。如果成功,实例被分配给引用变量x

main()接下来调用ClassField getField(String name)方法返回一个Field实例,代表由name标识的public字段,恰好是i(第一种情况)和PI(第二种情况)。当命名字段不存在时,这个方法抛出java.lang.NoSuchFieldException

接下来,main()调用FieldgetInt()setInt()方法(使用对象引用)来获取实例字段的初始值,将该值更改为另一个值,并获取新值。输出初始值和新值。

至此,main()以类似的方式演示了类字段访问。然而,它将null传递给getInt()setInt(),因为访问类字段不需要对象引用。因为PI被声明为final,所以对setInt()的调用导致了一个IllegalAccessException类的抛出实例。

images 注意我已经指定了catch (Exception e)以避免必须指定多个 catch 块。你也可以在适当的时候使用多批次(见第三章)。

Method表示一个方法,并声明各种方法,包括以下方法:

  • int getModifiers()返回一个 32 位整数,其位字段标识方法的保留字修饰符(如publicabstractstatic)。这些位域必须通过Modifier类来解释。例如,您可以指定(method.getModifiers()&Modifier.ABSTRACT) == Modifier.ABSTRACT来确定方法(由Method对象表示,该对象的引用存储在method中)是否是抽象的——当方法是抽象的时,该表达式的计算结果为 true。
  • Class<?> getReturnType()返回一个代表方法返回类型的Class对象。
  • Object invoke(Object receiver, Object... args)调用由receiver标识的对象上的方法(当该方法是类方法时被忽略),将由args标识的可变数量的参数传递给被调用的方法。当receivernull并且被调用的方法是实例方法时,invoke()方法抛出NullPointerException,当方法不可访问时(例如,它是私有的),当传递给方法的参数数量不正确时(以及其他原因),抛出IllegalArgumentException,当被调用的方法抛出异常时,抛出InvocationTargetException
  • 当方法被声明为接收可变数量的参数时,返回 true。

清单 4-15 演示了Methodinvoke(Object, Object...)方法。

清单 4-15。反射性地调用实例和类方法

import java.lang.reflect.Method;

class X
{
   public void objectMethod(String arg)
   {
      System.out.println("Instance method: "+arg);
   }
   public static void classMethod()
   {
      System.out.println("Class method");
   }
}
class MethodInvocationDemo
{
   public static void main(String[] args)
   {
      try
      {
         Class<?> clazz = Class.forName("X");
         X x = (X) clazz.newInstance();
         Class[] argTypes = { String.class };
         Method method = clazz.getMethod("objectMethod", argTypes);
         Object[] data = { "Hello" };
         method.invoke(x, data); // Output: Instance method: Hello
         method = clazz.getMethod("classMethod", (Class<?>[]) null);
         method.invoke(null, (Object[]) null); // Output: Class method
      }
      catch (Exception e)
      {
         System.err.println(e);
      }
   }
}

清单 4-15 声明了类XMethodInvocationDemoMethodInvocationDemomain()方法首先尝试加载X,然后尝试通过newInstance()实例化这个类。如果成功,实例被分配给引用变量x

main()接下来创建一个描述objectMethod()参数列表类型的单元素Class数组。该数组在随后对ClassMethod getMethod(String name, Class<?>... parameterTypes)方法的调用中使用,以返回一个Method对象,用于调用带有该参数列表的public方法objectMethod。当命名方法不存在时,这个方法抛出java.lang.NoSuchMethodException

接下来,main()创建一个Object数组,指定要传递给方法参数的数据;在这种情况下,数组由一个String参数组成。然后,它反射性地调用objectMethod(),将这个数组以及存储在x中的对象引用传递给invoke()方法。

至此,main()展示了如何反射性地调用一个类方法。(Class<?>[])(Object[])强制转换用于抑制与可变数量的参数和空引用有关的警告消息。注意,调用类方法时,传递给invoke()的第一个参数是null

java.lang.reflect.AccessibleObject类是ConstructorFieldMethod的超类。这个超类提供了报告构造函数、字段或方法的可访问性的方法(它是私有的吗?)并将不可访问的构造函数、字段或方法变为可访问的。AccessibleObject的方法包括以下几种:

  • T getAnnotation(Class<T> annotationType)返回指定类型的构造函数、字段或方法的注释(如果有这样的注释的话)。否则,null 返回。
  • 当构造函数、字段或方法可访问时,返回 true。
  • 当在构造函数、字段或方法上声明了由annotationType指定的类型的注释时,boolean isAnnotationPresent(Class<? extends Annotation> annotationType)返回 true。该方法考虑了继承的注释。
  • flagtrue时,void setAccessible(boolean flag)试图将不可访问的构造函数、字段或方法变为可访问的。

images 注意java.lang.reflect包还包含一个Array类,它的类方法使得反射式创建和访问 Java 数组成为可能。

我之前向您展示了如何通过ClassforName()方法获得一个Class对象。另一种获得Class对象的方法是在对象引用上调用ObjectgetClass()方法;比如Employee e = new Employee(); Class<? extends Employee> clazz = e.getClass();。因为创建对象的类已经存在于内存中,所以getClass()方法不会抛出异常。

还有一种方法可以获得一个Class对象,那就是使用一个类文字,它是一个表达式,由类名、句点分隔符、保留字class组成。类文字的例子包括Class<Employee> clazz = Employee.class;Class<String> clazz = String.class

也许您想知道如何在forName()getClass()和类文字之间进行选择。为了帮助您做出选择,以下列表对每个竞争对手进行了比较:

  • forName()非常灵活,因为您可以通过包限定名动态指定任何引用类型。如果该类型不在内存中,则加载、链接并初始化它。然而,缺乏编译时类型安全会导致运行时失败。
  • getClass()返回一个描述其引用对象类型的Class对象。如果在包含子类实例的超类变量上调用,则返回代表子类类型的Class对象。因为类在内存中,所以类型安全是有保证的。
  • 一个类文字返回一个代表其指定类的Class对象。类文本是紧凑的,当编译器找不到文本的指定类时,它通过拒绝编译源代码来强制类型安全。

images 注意你可以使用原始类型的类文字,包括void。例子包括int.classdouble.classvoid.class。返回的Class对象表示由原始类型包装类的TYPE字段或java.lang.Void.TYPE标识的类。例如,int.class == Integer.TYPEvoid.class == Void.TYPE中的每一个都评估为真。

您还可以将类文本用于基于类型的基元数组。例子有int[].classdouble[].class。对于这些例子,返回的Class对象代表Class<int[]>Class<double[]>

字符串

String是本书提出的第一个预定义引用类型(在第一章)。这种类型的实例表示字符序列,或字符串。

与其他引用类型不同,Java 语言通过提供简化字符串处理的语法糖,对String类进行了特殊处理。例如,Java 将String favLanguage = "Java";识别为字符串文字"Java"String变量favLanguage的赋值。没有这个糖,你就得指定String favLanguage = new String("Java");。Java 语言还重载了++=操作符来执行字符串连接。

表 4-4 描述了一些String的构造函数和方法,用于初始化String对象和处理字符串。

images

images

images

表 4-4 揭示了几个关于String的有趣项目。首先,这个类的String(String s)构造函数没有将String对象初始化为字符串文字。相反,它的行为类似于 C++复制构造函数,将String对象初始化为另一个String对象的内容。这种行为表明,字符串文字不仅仅是它看起来的样子。

实际上,字符串是一个String对象。你可以通过执行System.out.println("abc".length());System.out.println("abc" instanceof String);来证明这一点。第一个方法调用输出3,它是"abc" String对象的字符串长度,第二个方法调用输出true ( "abc"是一个String对象)。

images 注意字符串被存储在一个叫做常量池的类文件数据结构中。当加载一个类时,为每个字面值创建一个String对象,并存储在一个String对象的内部表中。

第二个有趣的项目是intern()方法,该方法String对象的内部表中实习(存储一个String对象的唯一副本)。intern()可以通过字符串的引用和==!=来比较字符串。这些运算符是比较字符串的最快方法,在对大量字符串进行排序时尤其有用。

默认情况下,由文字字符串("abc")和字符串值常量表达式("a"+"bc")表示的String对象被保留在该表中,这就是为什么System.out.println("abc" == "a"+"bc");输出true的原因。然而,通过String构造函数创建的String对象并没有被 interned,这就是为什么System.out.println("abc" == new String("abc"));会输出false。相比之下,System.out.println("abc" == new String("abc").intern());输出true

images 警告小心使用这种字符串比较技术(它只比较引用),因为当被比较的字符串之一没有被保留时,你很容易引入一个错误。如有疑问,使用equals()equalsIgnoreCase()方法。

表 4-4 还揭示了charAt()length()方法,这对于迭代字符串的字符很有用。例如,String s = "abc"; for (int i = 0; i < s.length(); i++) System.out.println(s.charAt(i));返回sabc字符,并在单独的行上输出每个字符。

最后,表 4-4 展示了split(),这是我在第三章的StubFinder应用中使用的一种方法,用于将一个字符串的逗号分隔的值列表分割成一个String对象数组。此方法使用一个正则表达式来标识字符串拆分所围绕的字符序列。(我在附录 c 中讨论正则表达式。)

images 注意 StringIndexOutOfBoundsExceptionArrayIndexOutOfBoundsException是共享一个公共java.lang.IndexOutOfBoundsException超类的兄弟类。

StringBuffer 和 StringBuilder

String对象是不可变的:你不能修改String对象的字符串。各种看似修改了String对象的String方法实际上返回了一个修改了字符串内容的新的String对象。因为返回新的String对象通常很浪费,所以 Java 提供了java.lang.StringBufferjava.lang.StringBuilder类作为解决方法。这些类是相同的,除了StringBuffer可以在多线程的环境中使用(在本章后面讨论),并且StringBuilderStringBuffer快,但是在没有显式同步的情况下不能在多线程的环境中使用(也在本章后面讨论)。

表 4-5 描述了一些StringBuffer的构造函数和方法,用于初始化StringBuffer对象和处理字符串缓冲区。StringBuilder的构造函数和方法是相同的。

images

images

images

一个StringBufferStringBuilder对象的内部数组与容量和长度的概念相关联。容量指的是在数组增长以容纳更多字符之前,数组中可以存储的最大字符数。 Length 指数组中已经存储的字符数。

本章前面介绍的toAlignedBinaryString()方法在其实现中包含以下低效循环:

int numLeadingZeros = numBits-result.length();
String zerosPrefix = "";
for (int j = 0; j < numLeadingZeros; j++)
   zerosPrefix += "0";

这个循环是低效的,因为每次迭代都创建一个StringBuilder对象和一个String对象。编译器将该代码片段转换为以下片段:

int numLeadingZeros = 3;
String zerosPrefix = "";
for (int j = 0; j < numLeadingZeros; j++)
   zerosPrefix = new StringBuilder().append(zerosPrefix).append("0").toString();

对前一个循环进行编码的一种更有效的方法包括在进入循环之前创建一个StringBuffer / StringBuilder对象,在循环中调用适当的append()方法,并在循环之后调用toString()。以下代码片段演示了这种更高效的场景:

int numLeadingZeros = 3;
StringBuilder sb = new StringBuilder();
for (int j = 0; j < numLeadingZeros; j++)
   sb.append("0");
String zerosPrefix = sb.toString();

images 注意避免在冗长的循环中使用字符串连接操作符,因为这会导致创建许多不必要的StringBuilderString对象。

系统

java.lang.System类提供对面向系统的资源的访问,包括标准输入、标准输出和标准错误。

System声明了分别支持标准输入、标准输出和标准误差的inouterr类字段。第一个字段属于类型java.io.InputStream,最后两个字段属于类型java.io.PrintStream。(我会在第八章正式介绍这些类。)

System还声明了各种static方法,包括那些在表 4-6 中描述的方法。

images

images

清单 4-16 展示了arraycopy()currentTimeMillis()getProperty()方法。

清单 4-16。试验System方法

class SystemTasks
{
   public static void main(String[] args)
   {
      int[] grades = { 86, 92, 78, 65, 52, 43, 72, 98, 81 };
      int[] gradesBackup = new int[grades.length];
      System.arraycopy(grades, 0, gradesBackup, 0, grades.length);
      for (int i = 0; i < gradesBackup.length; i++)
         System.out.println(gradesBackup[i]);
      System.out.println("Current time: "+System.currentTimeMillis());
      String[] propNames =
      {
         "java.vendor.url",
         "java.class.path",
         "user.home",
         "java.class.version",
         "os.version",
         "java.vendor",
         "user.dir",
         "user.timezone",
         "path.separator",
         "os.name",
         "os.arch",
         "line.separator",
         "file.separator",
         "user.name",
         "java.version",
         "java.home"
      };
      for (int i = 0; i < propNames.length; i++)
         System.out.println(propNames[i]+": "+
                            System.getProperty(propNames[i]));
   }
}

清单 4-16 的main()方法从演示arraycopy()开始。它使用这个方法将一个grades数组的内容复制到一个gradesBackup数组中。

images 提示arraycopy()方法是将一个数组复制到另一个数组的最快方法。同样,当你编写一个类,它的方法返回一个对内部数组的引用时,你应该使用arraycopy()来创建一个数组的副本,然后返回该副本的引用。这样,您可以防止客户端直接操作(并且可能搞砸)内部数组。

main() next 调用currentTimeMillis()以毫秒值返回当前时间。因为这个值不是人类可读的,所以您可能想要使用java.util.Date类(在附录 C 中讨论)。Date()构造函数调用currentTimeMillis(),它的toString()方法将这个值转换成可读的日期和时间。

main()通过在 for 循环中演示getProperty()得出结论。这个循环遍历所有的表 4-6 的属性名,输出每个名称和值。

当我在我的平台上运行这个应用时,它会生成以下输出:

86
92
78
65
52
43
72
98
81
Current time: 1312236551718
java.vendor.url: http://java.oracle.com/
java.class.path: .
user.home: C:\Documents and Settings\Jeff Friesen
java.class.version: 51.0
os.version: 5.1
java.vendor: Oracle Corporation
user.dir: C:\prj\dev\bj7\ch04\code\SystemTasks
user.timezone:
path.separator: ;
os.name: Windows XP
os.arch: x86
line.separator:

file.separator: \
user.name: Jeff Friesen
java.version: 1.7.0
java.home: C:\Program Files\Java\jdk1.7.0\jre

images 注意 line.separator存储的是实际的行分隔符字符/字符,而不是其表示形式(如\r\n),这也是为什么line.separator:后面会出现一个空行的原因。

穿线

应用通过线程执行,这些线程是应用代码的独立执行路径。当多个线程正在执行时,每个线程的路径可以不同于其他线程的路径。例如,一个线程可能执行 switch 语句的一个案例,而另一个线程可能执行该语句的另一个案例。

images 注意应用使用线程来提高性能。一些应用可以只使用默认的主线程来执行它们的任务,但其他应用需要额外的线程来在后台执行时间密集型任务,以便它们能够对用户做出响应。

JVM 为每个线程提供了自己的方法调用堆栈,以防止线程相互干扰。独立的堆栈让线程能够跟踪它们要执行的下一条指令,这些指令可能因线程而异。堆栈还为线程提供自己的方法参数、局部变量和返回值的副本。

Java 通过其线程 API 支持线程。这个 API 由java.lang包中的一个接口(Runnable)和四个类(ThreadThreadGroupThreadLocalInheritableThreadLocal)组成。在探索了RunnableThread(在这个探索过程中提到了ThreadGroup)之后,本节将探讨线程同步、ThreadLocalInheritableThreadLocal

images 注意 Java 5 引入了java.util.concurrent包作为低级线程 API 的高级替代。(我会在第六章讨论这个包。)尽管java.util.concurrent是处理线程的首选 API,但您也应该对线程有所了解,因为它在简单的线程场景中很有帮助。此外,您可能需要分析其他人依赖线程的源代码。

可运行和线程

Java 提供了Runnable接口来识别那些为线程提供代码的对象,线程通过这个接口唯一的void run()方法来执行——线程不接收参数,也不返回值。类实现了Runnable来提供这段代码,其中一个类是Thread

Thread为底层操作系统的线程架构提供一致的接口。(操作系统通常负责创建和管理线程。)Thread使代码与线程相关联,以及启动和管理那些线程成为可能。每个Thread实例与一个线程相关联。

Thread声明了几个用于初始化Thread对象的构造函数。这些构造函数中的一些采用了Runnable参数:你可以提供代码来运行,而不必扩展Thread。其他构造函数不接受Runnable参数:您必须扩展Thread并覆盖它的run()方法来提供要运行的代码。

例如,Thread(Runnable runnable)将新的Thread对象初始化为指定的runnable,其代码将被执行。相反,Thread()不会将Thread初始化为Runnable参数。相反,你的Thread子类提供了一个调用Thread()的构造函数,并且子类也覆盖了Threadrun()方法。

在没有显式名称参数的情况下,每个构造函数给Thread对象分配一个唯一的默认名称(以Thread-开始)。名字使得区分线程成为可能。与选择默认名称的前两个构造函数不同,Thread(String threadName)让您指定自己的线程名称。

Thread还声明了启动和管理线程的方法。表 4-7 描述了许多更有用的方法。

images

images

清单 4-17 通过演示RunnableThread(Runnable runnable)currentThread()getName()start()main()方法向您介绍线程 API。

清单 4-17。一对计数线

class CountingThreads {    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          String name = Thread.currentThread().getName();                          int count = 0;                          while (true)                             System.out.println(name+": "+count++);                       }                    };       Thread thdA = new Thread(r);       Thread thdB = new Thread(r);       thdA.start();       thdB.start();    } }

根据清单 4-17 ,执行main()的默认主线程首先实例化一个实现Runnable的匿名类。然后它创建两个Thread对象,将每个对象初始化为 runnable,并调用Threadstart()方法来创建和启动两个线程。完成这些任务后,主线程退出main()并死亡。

两个启动的线程都执行 runnable 的run()方法。它调用ThreadcurrentThread()方法以获得其关联的Thread实例,使用该实例调用ThreadgetName()方法以返回其名称,将count初始化为 0,并进入一个无限循环,在该循环中输出namecount,并在每次迭代中递增count

images 提示要停止一个没有结束的应用,同时按下 Ctrl 和 C 键(至少在 Windows 平台上)。

当我在 Windows XP 平台上运行这个应用时,我观察到两个线程交替执行。一次运行的部分输出如下所示:

Thread-0: 0
Thread-0: 1
Thread-0: 2
Thread-0: 3
Thread-0: 4
Thread-0: 5
Thread-0: 6
Thread-0: 7
Thread-1: 0
Thread-1: 1
Thread-1: 2
Thread-1: 3

操作系统为每个处理器或内核分配一个单独的线程,因此线程同时执行(同时)。当计算机没有足够的处理器和/或内核时,线程必须等待轮到它使用共享的处理器/内核。

操作系统使用一个调度器* ( [en.wikipedia.org/wiki/Scheduling_(computing)](http://en.wikipedia.org/wiki/Scheduling_(computing)))来决定一个等待线程何时执行。下表列出了三种不同的调度程序:

  • Linux 2.6 到 2.6.22 使用 O(1)调度程序([en.wikipedia.org/wiki/O(1)_scheduler](http://en.wikipedia.org/wiki/O(1)_scheduler))。
  • Linux 2.6.23 使用完全公平调度器([en.wikipedia.org/wiki/Completely_Fair_Scheduler](http://en.wikipedia.org/wiki/Completely_Fair_Scheduler))。
  • 基于 Windows NT 的操作系统(如 NT、2000、XP、Vista 和 7)使用多级反馈队列调度程序([en.wikipedia.org/wiki/Multilevel_feedback_queue](http://en.wikipedia.org/wiki/Multilevel_feedback_queue))。

计数线程应用之前的输出是通过 Windows XP 的多级反馈队列调度程序运行该应用产生的。由于这个调度程序,两个线程轮流执行。

images 注意虽然这个输出表示第一个线程开始执行,但是千万不要认为与第一个调用了start()方法的Thread对象相关联的线程是第一个执行的线程。虽然这可能适用于某些调度程序,但可能不适用于其他调度程序。

多级反馈队列和许多其他线程调度器考虑了优先级(线程相对重要性)的概念。他们经常将抢占式调度(优先级较高的线程抢占—中断并运行,而不是——优先级较低的线程)与循环调度(优先级相等的线程被给予相等的时间片,这些时间片被称为时间片,轮流执行)。

Thread通过其void setPriority(int priority)方法(将该Thread对象线程的优先级设置为priority,范围从Thread.MIN_PRIORITYThread.MAX_PRIORITYThread.NORMAL_PRIORITY标识默认优先级)和int getPriority()方法(返回当前优先级)支持优先级。

images 注意使用setPriority()方法会影响应用跨平台的可移植性,因为不同的调度程序可以用不同的方式处理优先级的变化。例如,一个平台的调度程序可能会延迟低优先级线程的执行,直到高优先级线程完成。这种延迟会导致无限期推迟饥饿,因为优先级较低的线程在无限期等待执行时会“饥饿”,这会严重损害应用的性能。另一个平台的调度程序可能不会无限期地延迟较低优先级的线程,从而提高应用的性能。

清单 4-18 重构清单 4-17 的main()方法,给每个线程一个非默认的名字,输出namecount后让每个线程休眠。

清单 4-18。重温一对数数线

class CountingThreads {    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          String name = Thread.currentThread().getName();                          int count = 0;                          while (true)                          {                             System.out.println(name+": "+count++);                             try                             {                                Thread.sleep(100);                             }                             catch (InterruptedException ie)                             {                             }                          }                       }                    };       Thread thdA = new Thread(r);       thdA.setName("A");       Thread thdB = new Thread(r);       thdB.setName("B");       thdA.start();       thdB.start();    } }

清单 4-18 显示线程 A 和 B 执行Thread.sleep(100);休眠 100 毫秒。这种休眠会导致每个线程更频繁地执行,如以下部分输出所示:

A: 0
B: 0
A: 1
B: 1
A: 2
B: 2
A: 3
B: 3

一个线程偶尔会启动另一个线程来执行冗长的计算、下载大文件或执行其他一些耗时的活动。在完成其他任务后,启动工作线程的线程准备好处理工作线程的结果,并等待工作线程完成和终止。

可以通过使用 while 循环来等待工作线程死亡,该循环在工作线程的Thread对象上重复调用ThreadisAlive()方法,并在该方法返回 true 时休眠一段时间。然而,清单 4-19 展示了一个不那么冗长的替代方法:join()方法。

清单 4-19。加入默认主线程和后台线程

class JoinDemo {    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          System.out.println("Worker thread is simulating "+                                             "work by sleeping for 5 seconds.");                          try                          {                             Thread.sleep(5000);                          }                          catch (InterruptedException ie)                          {                          }                          System.out.println("Worker thread is dying");                       }                    };       Thread thd = new Thread(r);       thd.start();       System.out.println("Default main thread is doing work.");       try       {          Thread.sleep(2000);       }       catch (InterruptedException ie)       {       }       System.out.println("Default main thread has finished its work.");       System.out.println("Default main thread is waiting for worker thread "+                          "to die.");       try       {          thd.join();       }       catch (InterruptedException ie)       {       }       System.out.println("Main thread is dying");    } }

清单 4-19 演示了默认主线程启动一个工作线程,执行一些工作,然后通过工作线程的thd对象调用join()来等待工作线程死亡。当您运行此应用时,您将发现类似如下的输出(消息顺序可能会有所不同):

Default main thread is doing work.
Worker thread is simulating work by sleeping for 5 seconds.
Default main thread has finished its work.
Default main thread is waiting for worker thread to die.
Worker thread is dying
Main thread is dying

每个Thread对象都属于某个ThreadGroup对象;Thread声明了一个返回该对象的ThreadGroup getThreadGroup()方法。您应该忽略线程组,因为它们并不那么有用。如果需要对Thread对象进行逻辑分组,应该使用数组或集合。

images 小心各种ThreadGroup方法都有漏洞。例如,当threads数组参数太小而无法存储它们的Thread对象时,int enumerate(Thread[] threads)将不会在其枚举中包含所有活动线程。虽然您可能认为可以使用来自int activeCount()方法的返回值来适当地调整这个数组的大小,但是不能保证这个数组足够大,因为activeCount()的返回值会随着线程的创建和死亡而波动。

然而,您仍然应该知道ThreadGroup,因为它在处理线程执行时抛出的异常方面做出了贡献。清单 4-20 通过呈现一个试图将整数除以 0 的run()方法,为学习异常处理搭建了舞台,这导致了一个抛出的java.lang.ArithmeticException实例。

清单 4-20。run()方法中抛出异常

class ExceptionThread
{
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         int x = 1/0;
                      }
                   };
      Thread thd = new Thread(r);
      thd.start();
   }
}

运行这个应用,您将看到一个异常跟踪,它标识了抛出的ArithmeticException:

Exception in thread "Thread-0" java.lang.ArithmeticException: / by zero
        at ExceptionThread$1.run(ExceptionThread.java:10)
        at java.lang.Thread.run(Thread.java:722)

当从run()方法中抛出异常时,线程终止,并发生以下活动:

  • JVM 寻找通过Threadvoid setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh)方法安装的Thread.UncaughtExceptionHandler的实例。当找到这个处理程序时,它将执行传递给实例的void uncaughtException(Thread t, Throwable e)方法,其中t标识抛出异常的线程的Thread对象,而e标识抛出的异常或错误——可能是抛出了一个OutOfMemoryError实例。如果这个方法抛出一个异常/错误,JVM 会忽略这个异常/错误。
  • 假设没有调用setUncaughtExceptionHandler()来安装处理程序,JVM 将控制权传递给关联的ThreadGroup对象的uncaughtException(Thread t, Throwable e)方法。假设ThreadGroup没有被扩展,并且它的uncaughtException()方法没有被覆盖来处理异常,当父ThreadGroup存在时,uncaughtException()将控制传递给父ThreadGroup对象的uncaughtException()方法。否则,它检查是否安装了默认的未捕获异常处理程序(通过Threadstatic void setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandler handler)方法)。)如果已经安装了一个默认的未捕获异常处理程序,那么用同样的两个参数调用它的uncaughtException()方法。否则,uncaughtException()检查它的Throwable参数以确定它是否是java.lang.ThreadDeath的实例。如果是,则不做任何特殊处理。否则,如清单 4-20 的异常消息所示,包含线程名称的消息(从线程的getName()方法返回)和堆栈回溯(使用Throwable参数的printStackTrace()方法)被打印到标准错误流。

清单 4-21 演示了ThreadsetUncaughtExceptionHandler()setDefaultUncaughtExceptionHandler()方法。

清单 4-21。演示未捕获的异常处理程序

class ExceptionThread {    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          int x = 1/0;                       }                    };       Thread thd = new Thread(r);       Thread.UncaughtExceptionHandler uceh;       uceh = new Thread.UncaughtExceptionHandler()              {                 public void uncaughtException(Thread t, Throwable e)                 {                    System.out.println("Caught throwable "+e+" for thread "+t);                 }              };       thd.setUncaughtExceptionHandler(uceh);       uceh = new Thread.UncaughtExceptionHandler()              {                 public void uncaughtException(Thread t, Throwable e)                 {                    System.out.println("Default uncaught exception handler");                    System.out.println("Caught throwable "+e+" for thread "+t);                 }              };       thd.setDefaultUncaughtExceptionHandler(uceh);       thd.start();    } }

当您运行此应用时,您将观察到以下输出:

Caught throwable java.lang.ArithmeticException: / by zero for thread ThreadThread-![images0,5,main]

您也不会看到默认的未捕获异常处理程序的输出,因为默认处理程序没有被调用。要查看输出,您必须注释掉thd.setUncaughtExceptionHandler(uceh);。如果你也注释掉thd.setDefaultUncaughtExceptionHandler(uceh);,你会看到清单 4-20 的输出。

images 注意 Thread声明了几个不推荐使用的方法,包括stop()(停止正在执行的线程)。这些方法已被弃用,因为它们不安全。不要而不是使用这些被否决的方法。(我将在本章的后面向你展示如何安全地停止一个线程。)此外,您应该避免使用static void yield()方法,该方法旨在将执行从当前线程切换到另一个线程,因为它会影响可移植性并损害应用性能。尽管在某些平台上yield()可能会切换到另一个线程(这可以提高性能),但在其他平台上yield()可能只会返回到当前线程(这会影响性能,因为yield()调用只是浪费了时间)。

线程同步

在整个执行过程中,每个线程都与其他线程相隔离,因为每个线程都有自己的方法调用堆栈。然而,当线程访问和操作共享数据时,它们仍然会相互干扰。这种干扰会破坏共享数据,这种破坏会导致应用失败。

例如,考虑一个丈夫和妻子共同使用的支票账户。假设夫妻双方同时决定清空这个账户,而不知道对方也在做同样的事情。清单 4-22 展示了这个场景。

清单 4-22。一个有问题的支票账户

class CheckingAccount {    private int balance;    CheckingAccount(int initialBalance)    {       balance = initialBalance;    }    boolean withdraw(int amount)    {       if (amount <= balance)       {          try          {             Thread.sleep((int)(Math.random()*200));          }          catch (InterruptedException ie)          {          }          balance -= amount;          return true;       }       return false;    }    public static void main(String[] args)    {       final CheckingAccount ca = new CheckingAccount(100);       Runnable r = new Runnable()                    {                       public void run()                       {                          String name = Thread.currentThread().getName();                          for (int i = 0; i < 10; i++)                              System.out.println (name+" withdraws $10: "+                                                  ca.withdraw(10));                       }                    };       Thread thdHusband = new Thread(r);       thdHusband.setName("Husband");       Thread thdWife = new Thread(r);       thdWife.setName("Wife");       thdHusband.start();       thdWife.start();    } }

这个应用允许提取比账户中可用金额更多的钱。例如,以下输出显示,当只有 100 美元可用时,提取了 110 美元:

Wife withdraws $10: true Wife withdraws $10: true Husband withdraws $10: true Wife withdraws $10: true Husband withdraws $10: true Wife withdraws $10: true Husband withdraws $10: true Husband withdraws $10: true Husband withdraws $10: true Husband withdraws $10: true Husband withdraws $10: false Husband withdraws $10: false Husband withdraws $10: false Husband withdraws $10: false Wife withdraws $10: true Wife withdraws $10: false Wife withdraws $10: false Wife withdraws $10: false Wife withdraws $10: false Wife withdraws $10: false

提取的钱比可用于提取的钱多的原因是在丈夫和妻子线程之间存在竞争条件。

images 注意 A 竞争条件是多个线程同时或几乎同时更新同一个对象的场景。对象的一部分存储由一个线程写入的值,对象的另一部分存储由另一个线程写入的值。

竞争条件的存在是因为检查取款金额以确保其少于余额中出现的金额并从余额中扣除该金额的操作不是原子(不可分割)操作。(虽然原子是可分的, atomic 通常用来指不可分的东西。)

images 注意Thread.sleep()方法调用会休眠一段可变的时间(最长可达 199 毫秒),这样您可以观察到提取的钱比可提取的钱多。如果没有这个方法调用,您可能需要执行应用数百次(或更多次)才能看到这个问题,因为调度器可能很少在amount <= balance表达式和balance -= amount;表达式语句之间暂停线程——代码执行得很快。

考虑以下场景:

  • 丈夫线程执行withdraw()amount <= balance表达式,返回 true。调度程序暂停丈夫线程,让妻子线程执行。
  • 妻子线程执行withdraw()amount <= balance表达式,返回 true。
  • 妻子线程执行撤回。调度程序暂停妻子线程,让丈夫线程执行。
  • 丈夫线程执行撤回。

这个问题可以通过同步对withdraw()的访问来解决,这样一次只有一个线程可以在这个方法中执行。通过在方法的返回类型之前将保留字synchronized添加到方法头,可以在方法级别同步访问;例如,synchronized boolean withdraw(int amount)

正如我稍后演示的,您还可以通过指定synchronized(*object*) { /* synchronized statements */ }来同步对语句块的访问,其中 object 是任意的对象引用。在执行离开方法/块之前,任何线程都不能进入同步的方法或块;这就是所谓的互斥

同步是根据监视器和锁实现的。一个监视器是一个并发结构,用于控制对一个临界区的访问,这是一个必须自动执行的代码区域。它在源代码级别被标识为同步方法或同步块。

是一个令牌,在监视器允许线程在监视器的临界区内执行之前,线程必须获取这个令牌。当线程退出监视器时,令牌被自动释放,以便给另一个线程一个获取令牌并进入监视器的机会。

images 注意一个已经获得锁的线程在调用Threadsleep()方法之一时不会释放这个锁。

进入同步实例方法的线程获取与调用该方法的对象相关联的锁。进入同步类方法的线程获取与该类的Class对象相关联的锁。最后,进入同步块的线程获得与该块的控制对象相关联的锁。

images 提示 Thread声明一个static boolean holdsLock(Object o)方法,当调用线程持有对象o上的监视器锁时,该方法返回 true。您会发现这种方法在断言语句中非常方便,比如assert Thread.holdsLock(o);

同步的需求通常是微妙的。例如,清单 4-23 的ID工具类声明了一个getNextID()方法,该方法返回一个唯一的基于long的 ID,可能会在生成唯一的文件名时使用。尽管您可能不这么认为,但此方法可能会导致数据损坏并返回重复值。

清单 4-23。一个用于返回唯一 id 的工具类

class ID
{
   private static long nextID = 0;
   static long getNextID()
   {
      return nextID++;
   }
}

getNextID()有两个不同步的问题。因为 32 位 JVM 实现需要两步来更新一个 64 位长的整数,所以给nextID加 1 不是原子性的:调度程序可能会中断一个只更新了一半nextID的线程,这会破坏这个变量的内容。

images 注意:longdouble类型的变量在 32 位 JVM 上的非同步上下文中被写入时会被破坏。对于booleanbytecharfloatintshort类型的变量,不会出现这个问题;每种类型占用 32 位或更少。

假设多线程调用getNextID()。因为 postincrement ( ++)分两步读写nextID字段,所以多个线程可能会检索到相同的值。例如,线程 A 执行++,读取nextID,但是在被调度器中断之前不增加它的值。线程 B 现在执行并读取相同的值。

这两个问题都可以通过同步对nextID的访问来解决,这样只有一个线程可以执行这个方法的代码。所需要做的就是在方法的返回类型之前将synchronized添加到方法头中;例如,static synchronized int getNextID()

同步也用于线程之间的通信。例如,你可以设计自己的机制来停止一个线程(因为你不能使用Thread的不安全的stop()方法来完成这个任务)。清单 4-24 展示了如何完成这项任务。

清单 4-24。试图停止一个线程

class ThreadStopping
{
   public static void main(String[] args)
   {
      class StoppableThread extends Thread
      {
         private boolean stopped = false;
         @Override
         public void run()
         {
            while(!stopped)
              System.out.println("running");
         }
         void stopThread()
         {
            stopped = true;
         }
      }
      StoppableThread thd = new StoppableThread();
      thd.start();
      try
      {
         Thread.sleep(1000); // sleep for 1 second
      }
      catch (InterruptedException ie)
      {
      }
      thd.stopThread();
   }
}

清单 4-24 引入了一个main()方法,该方法带有一个名为StoppableThread的局部类,该局部类是Thread的子类。StoppableThread声明了一个初始化为falsestopped字段,一个将该字段设置为truestopThread()方法,以及一个run()方法,其无限循环在每次循环迭代时检查stopped以查看其值是否已更改为true

实例化StoppableThread后,默认主线程启动与这个Thread对象关联的线程。然后它休眠一秒钟,并在死亡前调用StoppableThreadstop()方法。当您在单处理器/单核机器上运行这个应用时,您可能会看到应用停止了。

当应用在多处理器计算机或具有多个内核的单处理器计算机上运行时,您可能看不到这种停止。出于性能原因,每个处理器或内核可能都有自己的缓存,其中有自己的stopped副本。当一个线程修改这个字段的副本时,另一个线程的stopped的副本不会改变。

清单 4-25 重构清单 4-24 以保证应用能在各种机器上正确运行。

清单 4-25。在多处理器/多核机器上保证停止

class ThreadStopping
{
   public static void main(String[] args)
   {
      class StoppableThread extends Thread
      {
         private boolean stopped = false;
         @Override
         public void run()
         {
            while(!isStopped())
              System.out.println("running");
         }
         synchronized void stopThread()
         {
            stopped = true;
         }
         private synchronized boolean isStopped()
         {
            return stopped;
         }
      }
      StoppableThread thd = new StoppableThread();
      thd.start();
      try
      {
         Thread.sleep(1000); // sleep for 1 second
      }
      catch (InterruptedException ie)
      {
      }
      thd.stopThread();
   }
}

清单 4-25 的stopThread()isStopped()方法被同步以支持线程通信(在调用stopThread()的默认主线程和在run()内部执行的启动线程之间)。当一个线程进入这些方法之一时,它保证访问stopped字段的一个共享副本(不是缓存副本)。

同步是支持互斥或者互斥结合线程通信所必需的。然而,当唯一的目的是在线程之间通信时,有一种替代同步的方法。这个选项是保留字volatile,清单 4-26 展示了这个选项。

清单 4-26。线程通信同步的volatile替代

class ThreadStopping
{
   public static void main(String[] args)
   {
      class StoppableThread extends Thread
      {
         private volatile boolean stopped = false;
         @Override
         public void run()
         {
            while(!stopped)
              System.out.println("running");
         }
         void stopThread()
         {
            stopped = true;
         }
      }
      StoppableThread thd = new StoppableThread();
      thd.start();
      try
      {
         Thread.sleep(1000); // sleep for 1 second
      }
      catch (InterruptedException ie)
      {
      }
      thd.stopThread();
   }
}

清单 4-26 声明stoppedvolatile;访问该字段的线程将始终访问单个共享副本(而不是多处理器/多核机器上的缓存副本)。除了生成不那么冗长的代码,volatile还可能提供比同步更好的性能。

当一个字段被声明为volatile时,它也不能被声明为final。如果你依赖于波动性的语义,你仍然会从 ?? 领域得到这些。在他的“Java 理论和实践:修复 Java 内存模型,第二部分”文章([www.ibm.com/developerworks/library/j-jtp03304/](http://www.ibm.com/developerworks/library/j-jtp03304/))中,Brian Goetz 对这个问题有这样的看法:“新的 JMM [Java 内存模型]还试图提供一种新的初始化安全保证——只要对象被正确地构造(这意味着在构造函数完成之前没有发布对对象的引用),那么所有线程都将看到在其构造函数中设置的最终字段的值,不管是否使用同步来将引用从一个线程传递到另一个线程。此外,可以通过正确构造的对象的 final 字段到达的任何变量,例如由 final 字段引用的对象的字段,也保证对其他线程可见。这意味着,如果一个 final 字段包含对一个LinkedList的引用,那么除了引用的正确值对其他线程可见之外,该LinkedList的内容在构造时对其他线程也是可见的,而无需同步。其结果是对final意义的重大强化——final 字段可以在不同步的情况下安全地访问,编译器可以假设 final 字段不会改变,因此可以优化多次读取。”

images 注意你应该只在线程通信的上下文中使用volatile。此外,您只能在字段声明的上下文中使用该保留字。虽然您可以声明doublelong字段volatile,但是您应该避免在 32 位 JVM 上这样做,因为访问doublelong变量的值需要两次操作,并且需要通过同步互斥来安全地访问它们的值。

Objectwait()notify()notifyAll()方法支持一种线程通信形式,其中一个线程自愿等待某个条件(继续执行的先决条件)出现,此时另一个线程通知等待的线程它可以继续执行。wait()使其调用线程等待一个对象的监视器,notify()notifyAll()唤醒一个或所有等待监视器的线程。

images 注意因为wait()notify()notifyAll()方法依赖于锁,所以不能从同步方法或同步块的外部调用它们。如果您没有注意到这个警告,您将会遇到一个抛出的java.lang.IllegalMonitorStateException类实例。同样,一个已经获得锁的线程在调用Objectwait()方法之一时释放这个锁。

涉及条件的线程通信的一个经典例子是生产者线程和消费者线程之间的关系。生产者线程产生将由消费者线程消费的数据项。每个产生的数据项都存储在一个共享变量中。

假设线程没有通信,并且以不同的速度运行。生产者可能会生成一个新的数据项,并在消费者检索前一个数据项进行处理之前将其记录在共享变量中。此外,消费者可能会在生成新的数据项之前检索共享变量的内容。

为了克服这些问题,生产者线程必须等待,直到它被通知先前产生的数据项已经被消费,并且消费者线程必须等待,直到它被通知新的数据项已经被产生。清单 4-27 向你展示了如何通过wait()notify()来完成这个任务。

清单 4-27。生产者-消费者关系

class PC { public static void main(String[] args)    {       Shared s = new Shared();       new Producer(s).start();       new Consumer(s).start();    } } class Shared {    private char c = '\u0000';    private boolean writeable = true;    synchronized void setSharedChar(char c)    {       while (!writeable)          try          {             wait();          }          catch (InterruptedException e) {}       this.c = c;       writeable = false;       notify();    }    synchronized char getSharedChar()    {       while (writeable)          try          {             wait();          }          catch (InterruptedException e) {}       writeable = true;       notify();       return c;    } } class Producer extends Thread {    private Shared s;    Producer(Shared s)    {       this.s = s;    }    @Override    public void run()    {       for (char ch = 'A'; ch <= 'Z'; ch++)       {          synchronized(s)          {             s.setSharedChar(ch);             System.out.println(ch+" produced by producer.");          }       }    } } class Consumer extends Thread {    private Shared s;    Consumer(Shared s)    {       this.s = s;    }    @Override    public void run()    {       char ch;       do       {          synchronized(s)          {             ch = s.getSharedChar();             System.out.println(ch+" consumed by consumer.");          }       }       while (ch != 'Z');    } }

应用创建了一个Shared对象和两个线程,这两个线程获得了对象引用的副本。生产者调用对象的setSharedChar()方法来保存 26 个大写字母中的每一个;消费者调用对象的getSharedChar()方法来获取每个字母。

writeable实例字段跟踪两个条件:生产者等待消费者消费一个数据项,消费者等待生产者产生一个新的数据项。它有助于协调生产者和消费者的执行。下面的场景说明了这种协调,在该场景中,使用者首先执行:

  1. 消费者执行s.getSharedChar()来检索一封信。
  2. 在该同步方法内部,消费者调用wait(),因为writeable包含 true。消费者现在一直等到收到来自生产者的通知。
  3. 生产者最终执行s.setSharedChar(ch);
  4. 当生产者进入那个同步方法时(这是可能的,因为消费者在等待之前释放了wait()方法内部的锁),生产者发现writeable的值为真,并且不调用wait()
  5. 生产者保存角色,将writeable设置为false(这将导致生产者在消费者尚未消费该角色时等待下一个setSharedChar()调用),并调用notify()来唤醒消费者(假设消费者正在等待)。
  6. 生产者退出setSharedChar(char c)
  7. 消费者醒来(并重新获得锁),将writeable设置为true(这将导致消费者等待下一个getSharedChar()调用,而此时生产者还没有产生一个字符),通知生产者唤醒该线程(假设生产者正在等待),并返回共享字符。

尽管同步工作正常,但您可能会观察到输出(在某些平台上)在消费消息之前显示多个生产消息。例如,在应用输出的开始,您可能会看到A produced by producer.,然后是B produced by producer.,然后是A consumed by consumer.

这种奇怪的输出顺序是由于对setSharedChar()的调用后跟随其同伴System.out.println()方法调用不是原子的,以及对getSharedChar()的调用后跟随其同伴System.out.println()方法调用不是原子的。通过将这些方法调用对包装在同步块中来纠正输出顺序,该同步块在s引用的Shared对象上同步。

当您运行这个应用时,它的输出应该总是以相同的交替顺序出现,如下所示(为简洁起见,只显示了前几行):

A produced by producer.
A consumed by consumer.
B produced by producer.
B consumed by consumer.
C produced by producer.
C consumed by consumer.
D produced by producer.
D consumed by consumer.

images 注意不要在循环之外调用wait()。该循环在调用wait()之前和之后测试条件(在前面的例子中是!writeablewriteable)。在调用wait()之前测试条件可以确保的活性。如果该测试不存在,并且如果条件成立并且在调用wait()之前已经调用了notify(),则等待线程不太可能会醒来。调用wait()后重新测试条件确保安全。如果重新测试没有发生,并且如果在线程从wait()调用中唤醒后条件不成立(当条件不成立时,可能另一个线程意外地调用了notify()),线程将继续破坏锁的受保护不变量。

太多的同步可能会有问题。如果不小心的话,您可能会遇到这样的情况:锁被多个线程获取,没有一个线程持有自己的锁,而是持有其他线程所需的锁,没有一个线程能够进入并在以后退出其临界区以释放其持有的锁,因为其他线程持有该临界区的锁。清单 4-28 的非典型例子演示了这个场景,它被称为死锁

清单 4-28。一个死结的病理案例

class Deadlock {    private Object lock1 = new Object();    private Object lock2 = new Object();    void instanceMethod1()    {       synchronized(lock1)       {          synchronized(lock2)          {             System.out.println("first thread in instanceMethod1");             // critical section guarded first by             // lock1 and then by lock2          }       }    }    void instanceMethod2()    {       synchronized(lock2)       {          synchronized(lock1)          {             System.out.println("second thread in instanceMethod2");             // critical section guarded first by             // lock2 and then by lock1          }       }    }    public static void main(String[] args)    {       final Deadlock dl = new Deadlock();       Runnable r1 = new Runnable()                     {                        @Override                        public void run()                        {                           while(true)                              dl.instanceMethod1();                        }                     };       Thread thdA = new Thread(r1);       Runnable r2 = new Runnable()                     {                        @Override                        public void run()                        {                           while(true)                              dl.instanceMethod2();                        }                     };       Thread thdB = new Thread(r2);       thdA.start();       thdB.start();    } }

清单 4-28 的线程 A 和线程 B 分别在不同的时间调用instanceMethod1()instanceMethod2()。考虑以下执行顺序:

  1. 线程 A 调用instanceMethod1(),获取分配给lock1引用对象的锁,并进入其外部临界区(但尚未获取分配给lock2引用对象的锁)。
  2. 线程 B 调用instanceMethod2(),获取分配给lock2引用对象的锁,并进入其外部临界区(但尚未获取分配给lock1引用对象的锁)。
  3. 线程 A 试图获取与lock2相关联的锁。JVM 强制线程在内部临界区之外等待,因为线程 B 持有那个锁。
  4. 线程 B 试图获取与lock1相关联的锁。JVM 强制线程在内部临界区之外等待,因为线程 A 持有那个锁。
  5. 两个线程都无法继续,因为另一个线程持有所需的锁。我们遇到了死锁情况,程序(至少在两个线程的上下文中)冻结了。

尽管前面的例子清楚地标识了死锁状态,但是检测死锁通常并不容易。例如,您的代码可能包含不同类之间的以下循环关系(在几个源文件中):

  • 类 A 的同步方法调用类 B 的同步方法。
  • B 类的同步方法调用 C 类的同步方法。
  • C 类的同步方法调用 A 类的同步方法。

如果线程 A 调用类 A 的 synchronized 方法,而线程 B 调用类 C 的 synchronized 方法,那么当线程 B 试图调用类 A 的 synchronized 方法,而线程 A 仍在该方法内部时,线程 B 将会阻塞。线程 A 将继续执行,直到它调用类 C 的 synchronized 方法,然后阻塞。死锁结果。

images 注意Java 语言和 JVM 都没有提供防止死锁的方法,所以这个负担就落在了你的身上。防止死锁发生的最简单方法是避免同步方法或同步块调用另一个同步方法/块。虽然这个建议防止了死锁的发生,但是它是不切实际的,因为您的一个同步方法/块可能需要调用 Java API 中的一个同步方法,并且这个建议是多余的,因为被调用的同步方法/块可能不会调用任何其他同步方法/块,所以不会发生死锁。

您有时会想要将每个线程的数据(比如用户 ID)与一个线程相关联。虽然您可以使用局部变量来完成这项任务,但是您只能在局部变量存在时才能这样做。您可以使用实例字段将这些数据保存更长时间,但是这样您就必须处理同步问题。幸运的是,Java 提供了ThreadLocal作为一个简单(并且非常方便)的选择。

ThreadLocal类的每个实例描述了一个线程本地变量,这个变量为每个访问该变量的线程提供了一个单独的存储槽。您可以将线程局部变量视为一个多槽变量,其中每个线程可以在同一个变量中存储不同的值。每个线程只看到自己的值,不知道其他线程在这个变量中有自己的值。

ThreadLocal一般被声明为ThreadLocal<T>,其中T标识存储在变量中的值的类型。该类声明了以下构造函数和方法:

  • 创建一个新的线程局部变量。
  • T get()返回调用线程存储槽中的值。如果线程调用这个方法时条目不存在,get()调用initialValue()
  • 创建调用线程的存储槽,并在该槽中存储一个初始值(默认值)。初始值默认为 null。你必须子类化ThreadLocal并覆盖这个protected方法来提供一个更合适的初始值。
  • 移除调用线程的存储槽。如果这个方法后面跟随着get(),没有中间的set()get()调用initialValue()
  • void set(T value)将调用线程的存储槽的值设置为value

清单 4-29 展示了如何使用ThreadLocal将不同的用户 ID 与两个线程相关联。

清单 4-29。不同线程的不同用户 id

class ThreadLocalDemo {    private static volatile ThreadLocal<String> userID =       new ThreadLocal<String>();    public static void main(String[] args)    {       Runnable r = new Runnable()                    {                       @Override                       public void run()                       {                          String name = Thread.currentThread().getName();                          if (name.equals("A"))                             userID.set("foxtrot");                          else                             userID.set("charlie");                          System.out.println(name+" "+userID.get());                       }                    };       Thread thdA = new Thread(r);       thdA.setName("A");       Thread thdB = new Thread(r);       thdB.setName("B");       thdA.start();       thdB.start();    } }

在实例化ThreadLocal并将引用分配给名为userIDvolatile类字段(该字段为volatile,因为它由不同的线程访问,这可能在多处理器/多核机器上执行)之后,默认主线程创建另外两个线程,在userID中存储不同的String对象并输出它们的对象。

当您运行此应用时,您将观察到以下输出(可能不是这个顺序):

A foxtrot
B charlie

存储在线程局部变量中的值是不相关的。当一个新线程被创建时,它获得一个包含initialValue()值的新存储槽。也许你更愿意将一个值从一个父线程(一个创建另一个线程的线程)传递给一个子线程(一个被创建的线程)。你用InheritableThreadLocal完成这个任务。

InheritableThreadLocalThreadLocal的子类。除了声明一个InheritableThreadLocal()构造函数,这个类还声明了下面的protected方法:

  • T childValue(T parentValue)在创建子线程时,根据父线程的值计算子线程的初始值。在子线程启动之前,从父线程调用此方法。该方法返回传递给parentValue的参数,并且应该在需要另一个值时被覆盖。

清单 4-30 展示了如何使用InheritableThreadLocal将父线程的Integer对象传递给子线程。

清单 4-30。不同线程的不同用户 id

class InheritableThreadLocalDemo {    private static volatile InheritableThreadLocal<Integer> intVal =      new InheritableThreadLocal<Integer>();    public static void main(String[] args)    {       Runnable rP = new Runnable()                     {                        @Override                        public void run()                        {                           intVal.set(new Integer(10));                           Runnable rC = new Runnable()                                         {                                            public void run()                                            {                                               Thread thd;                                               thd = Thread.currentThread();                                               String name = thd.getName();                                               System.out.println(name+" "+                                                                  intVal.get());                                            }                                         };                           Thread thdChild = new Thread(rC);                           thdChild.setName("Child");                           thdChild.start();                        }                     };       new Thread(rP).start();    } }

在实例化InheritableThreadLocal并将其分配给一个名为intValvolatile类字段后,默认主线程创建一个父线程,它在intVal中存储一个包含 10 的Integer对象。父线程创建子线程,子线程访问intVal并检索其父线程的Integer对象。

当您运行此应用时,您将观察到以下输出:

Child 10

BigDecimal

第二章给你介绍了一个带balance字段的SavingsAccount类。我将这个字段声明为类型int,并提到balance代表可以提取的美元数。或者,我可以声明balance代表可以提取的便士数量。

也许你想知道为什么我没有声明balance是类型double或者float。这样,balance可以存储 18.26 这样的值(整数部分为 18 美元,小数部分为 26 便士)。我没有将balance声明为doublefloat,原因如下:

  • 并非所有可以表示货币数量(美元和美分)的浮点值都可以准确地存储在内存中。例如,0.1(可以用来表示 10 美分)没有精确的存储表示。如果您执行double total = 0.1; for (int i = 0; i < 50; i++) total += 0.1; System.out.println(total);,您将观察到5.099999999999998而不是正确的5.1作为输出。
  • 每个浮点计算的结果都需要四舍五入到最接近的分。否则会引入微小的误差,导致最终结果与正确结果不同。虽然Math提供了一对round()方法,您可能会考虑使用它们来将计算四舍五入到最接近的美分,但是这些方法四舍五入到最接近的整数(美元)。

清单 4-31 的InvoiceCalc应用演示了这两个问题。然而,第一个问题并不严重,因为它对不准确性的贡献很小。更严重的问题发生在执行计算后未能舍入到最接近的分。

清单 4-31。基于浮点的发票计算导致混乱的结果

`import java.text.NumberFormat;

class InvoiceCalc
{    final static double DISCOUNT_PERCENT = 0.1; // 10%
   final static double TAX_PERCENT = 0.05; // 5%
   public static void main(String[] args)
   {
      double invoiceSubtotal = 285.36;
      double discount = invoiceSubtotalDISCOUNT_PERCENT;
      double subtotalBeforeTax = invoiceSubtotal-discount;
      double salesTax = subtotalBeforeTax
TAX_PERCENT;
      double invoiceTotal = subtotalBeforeTax+salesTax;
      NumberFormat currencyFormat = NumberFormat.getCurrencyInstance();
      System.out.println("Subtotal: "+currencyFormat.format(invoiceSubtotal));
      System.out.println("Discount: "+currencyFormat.format(discount));
      System.out.println("SubTotal after discount: "+
                         currencyFormat.format(subtotalBeforeTax));
      System.out.println("Sales Tax: "+currencyFormat.format(salesTax));
      System.out.println("Total: "+currencyFormat.format(invoiceTotal));
   }
}`

清单 4-31 依赖于NumberFormat类(位于java.text包中)和它的format()方法将双精度浮点值格式化成一种货币——我在附录 c 的国际化部分讨论了NumberFormat。当你运行InvoiceCalc时,你会发现下面的输出:

Subtotal: $285.36
Discount: $28.54
SubTotal after discount: $256.82
Sales Tax: $12.84
Total: $269.67

此输出显示正确的小计、折扣、折扣后小计和销售税。相比之下,它错误地将 269.67 而不是 269.66 显示为最终总数。即使根据浮点计算,269.67 是正确的值,客户也不会愿意多付一分钱:

Subtotal: 285.36
Discount: 28.536
SubTotal after discount: 256.824
Sales Tax: 12.8412
Total: 269.6652

这个问题是由于在执行下一次计算之前,没有将每次计算的结果四舍五入到最接近的分位。因此,256.824 中的 0.024 和 12.84 中的 0.0012 构成了最终值,导致NumberFormatformat()方法将该值四舍五入为 269.67。

images 注意千万不要用floatdouble来表示货币值。

Java 以BigDecimal类的形式为这两个问题提供了解决方案。这个不可变的类(一个BigDecimal实例不能被修改)代表一个任意精度(位数)的有符号十进制数(比如 23.653)和一个关联的小数位数(指定小数点后位数的整数)。

BigDecimal声明了三个方便常数:ONETENZERO。每个常数都是 1、10 和 0 的BigDecimal等效值,刻度为零。

images 注意 BigDecimal声明了几个ROUND_前缀的常量。这些常量很大程度上已经过时了,应该避免使用,还有BigDecimal divide(BigDecimal divisor, int scale, int roundingMode)BigDecimal setScale(int newScale, int roundingMode)方法,它们仍然存在,因此相关的遗留代码可以继续编译。

BigDecimal还声明了各种有用的构造函数和方法。在表 4-8 中描述了其中一些构造函数和方法。

images

images

images

表 4-8 指的是java.math.RoundingMode,是包含各种舍入模式常数的枚举。这些常数在表 4-9 中描述。

images

适应BigDecimal的最好方法是尝试一下。清单 4-32 使用这个类来正确执行清单 4-31 中给出的发票计算。

清单 4-32。 BigDecimal基于发票的计算不会导致混乱的结果

`import java.math.BigDecimal;
import java.math.RoundingMode;

class InvoiceCalc
{
   public static void main(String[] args)
   {
      BigDecimal invoiceSubtotal = new BigDecimal("285.36");
      BigDecimal discountPercent = new BigDecimal("0.10");
      BigDecimal discount = invoiceSubtotal.multiply(discountPercent);       discount = discount.setScale(2, RoundingMode.HALF_UP);
      BigDecimal subtotalBeforeTax = invoiceSubtotal.subtract(discount);
      subtotalBeforeTax = subtotalBeforeTax.setScale(2, RoundingMode.HALF_UP);
      BigDecimal salesTaxPercent = new BigDecimal("0.05");
      BigDecimal salesTax = subtotalBeforeTax.multiply(salesTaxPercent);
      salesTax = salesTax.setScale(2, RoundingMode.HALF_UP);
      BigDecimal invoiceTotal = subtotalBeforeTax.add(salesTax);
      invoiceTotal = invoiceTotal.setScale(2, RoundingMode.HALF_UP);
      System.out.println("Subtotal: "+invoiceSubtotal);
      System.out.println("Discount: "+discount);
      System.out.println("SubTotal after discount: "+subtotalBeforeTax);
      System.out.println("Sales Tax: "+salesTax);
      System.out.println("Total: "+invoiceTotal);
   }
}`

清单 4-32 的main()方法首先创建BigDecimal对象invoiceSubtotaldiscountPercent,它们分别被初始化为285.360.10。然后将invoiceSubtotal乘以discountPercent,并将BigDecimal的结果赋给discount

至此,discount包含 28.5360。除了尾随零,该值与清单 4-31 中的invoiceSubtotal*DISCOUNT_PERCENT生成的值相同。应该存储在discount中的值是 28.54。为了在执行另一个计算之前纠正这个问题,main()用这些参数调用discountsetScale()方法:

  • 2:小数点后两位
  • RoundingMode.HALF_UP:传统的舍入方法

设置好比例和适当的舍入方式后,main()invoiceSubtotal中减去discount,并将得到的BigDecimal实例赋给subtotalBeforeTaxmain()调用subtotalBeforeTax上的setScale(),以便在进行下一次计算之前正确舍入其值。

main()接下来创建一个名为salesTaxPercentBigDecimal对象,它被初始化为0.05。然后,它将subtotalBeforeTax乘以salesTaxPercent,将结果赋给salesTax,并在这个BigDecimal对象上调用setScale()来适当地舍入它的值。

继续,main()salesTaxsubtotalBeforeTax相加,将结果保存在invoiceTotal中,并通过setScale()对结果进行舍入。这些对象中的值通过System.out.println()发送到标准输出设备,后者调用它们的toString()方法返回BigDecimal值的字符串表示。

当您运行这个新版本的InvoiceCalc时,您将发现以下输出:

Subtotal: 285.36
Discount: 28.54
SubTotal after discount: 256.82
Sales Tax: 12.84
Total: 269.66

images 注意 BigDecimal声明了一个BigDecimal(double val)构造函数,您应该尽可能避免使用它。这个构造函数将BigDecimal实例初始化为存储在val中的值,当double值不能准确存储时,这个实例可以反映一个无效的表示。例如, BigDecimal(0.1)导致0.1000000000000000055511151231257827021181583404541015625存储在实例中。相比之下,BigDecimal("0.1")恰恰存储了0.1

大整数

BigDecimal将带符号的十进制数存储为 32 位整数刻度的无刻度值。未缩放的值存储在BigInteger类的实例中。

BigInteger是一个不可变的类,表示任意精度的有符号整数。它以二进制补码格式存储它的值(所有位都翻转,从 1 到 0 和从 0 到 1,结果加 1,以与 Java 的字节整数、短整数、整数和长整数类型使用的二进制补码格式兼容)。

images 查看维基百科的“二进制补码”词条([en.wikipedia.org/wiki/Two's_complement](http://en.wikipedia.org/wiki/Two's_complement))了解更多关于二进制补码的知识。

BigInteger声明了三个方便常数:ONETENZERO。每个常数都是 1、10 和 0 的BigInteger等价物。

BigInteger还声明了各种有用的构造函数和方法。在表 4-10 中描述了其中一些构造函数和方法。

images

images

images 注意 BigInteger还声明了几个面向位的方法,比如BigInteger(BigInteger val)BigInteger flipBit(int n)BigInteger shiftLeft(int n)。当您需要执行低级位操作时,这些方法非常有用。

适应BigInteger的最好方法是尝试一下。清单 4-33 在factorial()方法比较上下文中使用了这个类。

清单 4-33。对比factorial()方法

import java.math.BigInteger;

class FactComp
{
   public static void main(String[] args)
   {
      System.out.println(factorial(12));
      System.out.println();
      System.out.println(factorial(20L));
      System.out.println();
      System.out.println(factorial(170.0));
      System.out.println();
      System.out.println(factorial(new BigInteger("170")));
      System.out.println();
      System.out.println(factorial(25.0));
      System.out.println();
      System.out.println(factorial(new BigInteger("25")));
   }
   static int factorial(int n)
   {
      if (n == 0)
         return 1;
      else
         return n*factorial(n-1);
   }
   static long factorial(long n)
   {
      if (n == 0)
         return 1;
      else
         return n*factorial(n-1);
   }
   static double factorial(double n)
   {
      if (n == 1.0)
         return 1.0;
      else
         return n*factorial(n-1);
   }
   static BigInteger factorial(BigInteger n)
   {
      if (n.equals(BigInteger.ZERO))
         return BigInteger.ONE;
      else
         return n.multiply(factorial(n.subtract(BigInteger.ONE)));
   }
}

清单 4-33 比较了递归factorial()方法的四个版本。这种比较揭示了在返回的阶乘值变得没有意义之前可以传递给前三个方法的最大参数,因为数值类型可以精确表示的值的范围是有限的。

第一个版本基于int,有一个从 0 到 12 的有用参数范围。传递任何大于 12 的参数都会导致阶乘无法精确地表示为int

您可以通过将参数和返回类型更改为long来增加factorial()的有用范围,但不会增加太多。做了这些改变后,你会发现有用范围的上限是 20。

为了进一步扩大有用的范围,您可以创建一个版本的factorial(),它的参数和返回类型都是double。这是可能的,因为整数可以精确地表示为double s。然而,可以传递的最大有用参数是 170.0。高于该值的任何值都会导致factorial()返回+无穷大。

您可能需要计算更高的阶乘值,也许是在计算涉及组合或排列的统计问题时。精确计算这个值的唯一方法是使用基于BigIntegerfactorial()版本。

当您运行这个应用时,如java FactComp所示,它会生成以下输出:

479001600

2432902008176640000

7.257415615307994E306

7257415615307998967396728211129263114716991681296451376543577798900561843401706157852350749242617459511490991237838520776666022565442753025328900773207510902400430280058295603966612599658257104398558294257568966313439612262571094946806711205568880457193340212661452800000000000000000000000000000000000000000

1.5511210043330986E25

15511210043330985984000000

前三个值表示基于intlongdoublefactorial()方法可以返回的最高阶乘。第四个值代表最高double阶乘的BigInteger等效值。

请注意,double方法无法准确表示 170!(!是阶乘的数学符号)。它的精度简直太小了。尽管该方法试图对最小的数字进行舍入,但舍入并不总是有效,因为数字以 7994 而不是 7998 结尾。正如最后两行输出所显示的,舍入只在参数 25.0 之前是准确的。

images 注意 RSA 加密、BigDecimal和阶乘是BigInteger有用的实际例子。然而,你也可以以不寻常的方式使用BigInteger。例如,我在 2006 年 2 月发表的标题为“用 Java 穿越时间”([www.javaworld.com/javaworld/jw-02-2006/jw-0213-funandgames.html](http://www.javaworld.com/javaworld/jw-02-2006/jw-0213-funandgames.html))的文章,是我的 Java 趣味和游戏系列的一部分,使用BigInteger将图像存储为一个非常大的整数。这个想法是用BigInteger方法进行实验,寻找过去存在、未来存在或可能永远不存在的人和地方的图像(可能通过发现数学模式)。如果这种疯狂吸引你,看看我的文章。

演习

以下练习旨在测试您对 Java 语言 API 的理解:

  1. 一个素数是一个大于 1 的正整数,可以被 1 和它本身整除。创建一个PrimeNumberTest应用,确定它的单个整数参数是素数还是非素数,并输出一个合适的消息。例如,java PrimeNumberTest 289应该输出消息289 is not prime。检查素性的一个简单方法是从 2 开始循环到整数参数的平方根,并在循环中使用余数运算符来确定参数是否被循环索引整除。例如,因为6/2产生余数 0 (2 被 6 整除),所以整数 6 不是素数。

  2. 反射在设备驱动程序上下文中很有用,应用需要与不同版本的驱动程序进行交互。如果检测到旧版本,应用将调用其方法。如果检测到较新的版本,应用可以调用较旧的方法或调用这些方法的较新版本。创建两个版本的Driver类。第一个版本声明了一个返回“basic capabilities”的String getCapabilities()方法,第二个版本声明了这个方法和一个返回“extended capabilities”的String getCapabilitiesEx()方法。创建一个DriverDemo类,它使用反射来确定当前的Driver.class类文件是否支持getCapabilitiesEx(),如果支持,就调用那个方法。如果该方法不存在,使用反射来确定它是否支持getCapabilities(),如果支持,则调用该方法。否则,输出一条错误消息。

  3. Java arrays have fixed lengths. Create a growable array class, GArray<E>, whose instances store objects of the type specified by the actual type argument passed to E. This class declares a GArray(int initCapacity) constructor that creates an internal array with the number of elements specified by initCapacity. Also, this class declares E get(int index) and void set(int index, E value) methods that respectively return the object at the index position within the internal array, and store the specified value in the array at the index position. The get() method must throw ArrayIndexOutOfBoundsException when the argument passed to index is out of range (negative or greater than/equal to the array's length). The set() method must throw the same exception when the argument passed to index is negative. However, when the argument is positive, it must create a new internal array whose size is twice that of the old array, copy elements from the old array to the new array via System.arraycopy(), and store the new value at the index position. This class also declares an int size() method that returns the array's size. Test this class with the GArrayDemo application described in Listing 4-34.

    清单 4-34。展示可扩展阵列

    `import ca.tutortutor.collections.GArray;

    class GArrayDemo
    {
       public static void main(String[] args)
       {
          GArray ga = new GArray<>(10);
          System.out.println("Size = "+ga.size());
          ga.set(3, "ABC");
          System.out.println("Size = "+ga.size());
          ga.set(22, "XYZ");
          System.out.println("Size = "+ga.size());
          System.out.println(ga.get(3));
          System.out.println(ga.get(22));
          System.out.println(ga.get(20));
          ga.set(20, "PQR");
          System.out.println(ga.get(20));
          System.out.println("Size = "+ga.size());
       }
    }`

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

    Size = 0 Size = 4 Size = 23 ABC XYZ null PQR Size = 23

  4. 修改清单 4-17 的CountingThreads应用,将两个计数线程标记为守护线程。运行结果应用时会发生什么?

  5. 修改清单 4-17 的CountingThreads应用,增加当用户按下回车键时停止两个线程计数的逻辑。默认主线程应该在终止前调用System.in.read(),并在该方法调用返回后将true赋给一个名为stopped的变量。每个计数线程应该在每次循环迭代开始时测试这个变量,看它是否包含 true,只有当变量包含 false 时才继续循环。

总结

Java 的标准类库通过java.langjava.math包提供了各种面向语言的 API。这些 API 包括MathStrictMathPackage、原始类型包装类、引用、反射、StringStringBufferStringBuilderSystem、线程、BigDecimalBigInteger

MathStrictMath类提供了各种有用的面向数学的方法,用于计算三角值、生成伪随机数等等。StrictMath不同于Math,它确保所有这些数学运算在所有平台上都产生相同的结果。

Package类提供了对包信息的访问。该信息包括关于 Java 包的实现和规范的版本细节、包的名称以及包是否被密封的指示。

BooleanByteCharacterDoubleFloatIntegerLongShort原始类型包装类的实例将自己包装在原始类型的值周围。这些类对于在集合中存储原始值很有用,并且提供了一个很好的地方来将有用的常量(如MAX_VALUEMIN_VALUE)和类方法(如IntegerparseInt()方法和CharacterisDigit()isLetter()toUpperCase()方法)与原始类型相关联。

引用 API 使得应用能够以有限的方式与垃圾收集器进行交互。这个 API 由类ReferenceReferenceQueueSoftReferenceWeakReferencePhantomReference组成。

SoftReference有助于实现图像缓存,WeakReference有助于防止与 hashmaps 相关的内存泄漏,PhantomReference有助于了解对象何时被终结,以便清理其资源。

反射 API 让应用了解加载的类、接口、枚举(一种类)和注释类型(一种接口)。它还允许应用动态加载类、实例化类、查找类的字段和方法、访问字段、调用方法,以及反射性地执行其他任务。

反射 API 的入口点是一个名为Class的特殊类。附加类别包括ConstructorFieldMethodAccessibleObjectArray

String类将一个字符串表示为一个字符序列。因为这个类的实例是不可变的,所以 Java 提供了StringBufferStringBuilder来更有效地构建字符串。前一个类可以在多线程上下文中使用,而后一个类更具性能。

System类提供对标准输入、标准输出、标准错误和其他面向系统的资源的访问。例如,System提供了arraycopy()方法,作为将一个数组复制到另一个数组的最快的可移植方法。

Java 通过其低级线程 API 支持线程。这个 API 由一个接口(Runnable)和四个类(ThreadThreadGroupThreadLocalInheritableThreadLocal)组成。

在整个执行过程中,每个线程都与其他线程相隔离,因为每个线程都有自己的方法调用堆栈。然而,当线程访问和操作共享数据时,它们仍然会相互干扰。这种干扰会破坏共享数据,这种破坏会导致应用失败。Java 提供了线程同步机制来防止这种干扰。

货币绝不能用浮点和双精度浮点变量来表示,因为不是所有的货币值都能精确表示。相比之下,BigDecimal类让您可以准确地表示和操作这些值。

BigDecimal依赖于BigInteger类来表示其未缩放的值。一个BigInteger实例描述了一个任意长度的整数值(受 JVM 内存的限制)。

本章简要介绍了集合框架,同时介绍了基本类型包装类 API。第五章向您介绍了这个用于收集对象的广泛的实用 API。*

五、收集对象

应用通常必须管理对象集合。尽管您可以为此使用数组,但它们并不总是一个好的选择。例如,数组有固定的大小,当您需要存储可变数量的对象时,很难确定最佳大小。此外,数组只能由整数索引,这使得它们不适合将任意对象映射到其他对象。

Java 的标准类库提供了集合框架和遗留 API 来代表应用管理集合。第五章首先介绍这个框架,然后向你介绍这些遗留 API(万一你在遗留代码中遇到)。因为框架和遗留 API 可能不能满足特定的需求,所以本章最后关注于创建特殊用途的集合 API。

images 注意 Java 的并发工具(在第六章中讨论)扩展了集合框架。

集合框架

集合框架是表示和操作集合的标准架构,集合是存储在为此目的设计的类实例中的对象组。在概述了这个框架的架构之后,本节将向您介绍促成这个架构的各种类型(主要位于java.util包中)。

架构概述

集合框架的架构分为三个部分:

  • 核心接口(Core interfaces):框架提供了核心接口,用于独立于集合的实现来操作集合。
  • 实现类:框架提供了提供不同核心接口实现的类,以解决性能和其他需求。
  • 工具类:框架提供了工具类,它们的方法可以让您对数组进行排序,获得同步的集合,并执行其他操作。

核心接口包括java.lang.IterableCollectionListSetSortedSetNavigableSetQueueDequeMapSortedMapNavigableMapCollection延伸IterableListSetQueue各延伸CollectionSortedSet延伸SetNavigableSet延伸SortedSetDeque延伸QueueSortedMap延伸MapNavigableMap延伸出SortedMap

图 5-1 展示了核心接口的层次结构(箭头指向父接口)。

images

图 5-1。集合框架基于核心接口的层次结构。

框架的实现类包括ArrayListLinkedListTreeSetHashSetLinkedHashSetEnumSetPriorityQueueArrayDequeTreeMapHashMapLinkedHashMapIdentityHashMapWeakHashMapEnumMap。每个具体类的名称都以核心接口名称结尾,标识它所基于的核心接口。

images 注意额外的实现类是并发工具的一部分。

框架的实现类还包括抽象的AbstractCollectionAbstractListAbstractSequentialListAbstractSetAbstractQueueAbstractMap类。这些类提供了核心接口的框架实现,以便于创建具体的实现类。

最后,框架提供了两个工具类:ArraysCollections

可比与比较

集合实现以某种顺序(排列)存储其元素。这个顺序可能是无序的,也可能是根据某种标准(如字母、数字或时间)排序的。

一个排序的集合实现默认按照元素的自然排序(??)来存储元素。比如String对象的自然排序是字典序字典序(也称字母顺序)。

集合不能依靠equals()来指定自然排序,因为这种方法只能确定两个元素是否等价。相反,元素类必须实现java.lang.Comparable<T>接口及其int compareTo(T o)方法。

images 根据Comparable的 Java 文档,这个接口被认为是集合框架的一部分,尽管它是java.lang包的成员。

已排序的集合使用compareTo()来确定该方法的元素参数o在集合中的自然顺序。compareTo()将参数o与当前元素(调用compareTo()的元素)进行比较,并执行以下操作:

  • 当当前元素应该在o之前时,它返回一个负值。
  • 当当前元素和o相同时,返回零值。
  • 当当前元素应该在o之后时,它返回一个正值。

当你需要实现ComparablecompareTo()方法时,有一些规则是你必须遵守的。下面列出的这些规则类似于第二章中的所示的实施equals()方法的规则:

  • compareTo()必须是自反的:对于任何非空的参考值 xx .compareTo(*x*)必须返回 0。
  • compareTo()必须对称:对于任何非空参考值x**yx .compareTo(*y*) == -y.compareTo(x)必须持有。
  • compareTo()必须传递:对于任何非空的参考值xyz,如果 x .compareTo(*y*) > 0为真,如果 y .compareTo(*z*) > 0为真,那么 x .compareTo(*z*) > 0也必须为真。

另外,当空引用被传递给这个方法时,compareTo()应该抛出NullPointerException。但是,您不需要检查 null,因为当这个方法试图访问一个 null 引用的成员时会抛出NullPointerException

images 注意在 Java 5 及其引入泛型之前,compareTo()的参数是类型java.lang.Object的,在进行比较之前必须转换成适当的类型。当参数的类型与强制转换不兼容时,强制转换操作符会抛出一个java.lang.ClassCastException实例。

您可能偶尔需要在集合中存储以不同于自然顺序的某种顺序排序的对象。在这种情况下,您需要提供一个比较器来提供这种排序。

一个比较器是一个对象,它的类实现了Comparator接口。这个接口的泛型类型是Comparator<T>,它提供了下面一对方法:

  • int compare(T o1, T o2)比较两个参数的顺序。该方法在o1等于o2时返回 0,在o1小于o2时返回负值,在o1大于o2时返回正值。
  • o“等于”这个Comparator时, boolean equals(Object o)返回 true,因为o也是一个Comparator,并采用相同的排序。否则,此方法返回 false。

images 注意 Comparator声明equals()是因为这个接口在这个方法的契约上加了一个额外的条件。此外,只有当指定的对象也是一个比较器,并且与该比较器采用相同的排序时,该方法才能返回 true。你不必覆盖Objectequals()方法,但是这样做可以通过允许程序确定两个不同的比较器施加相同的顺序来提高性能

第三章提供了一个说明实现Comparable的例子,在本章的后面你会发现另一个例子。此外,本章还将展示实现Comparator的例子。

可迭代和集合

大部分核心接口都植根于Iterable及其Collection子接口。它们的通用类型是Iterable<T>Collection<E>

Iterable描述任何能够以某种顺序返回其包含对象的对象。这个接口声明了一个Iterator<T> iterator()方法,该方法返回一个Iterator实例,用于迭代所有包含的对象。

Collection表示被称为元素的对象集合。该接口提供了许多集合所基于的Collection子接口所共有的方法。表 5-1 描述了这些方法。

image

image

image

image

表 5-1 揭示了各种Collection方法的三个例外。首先,一些方法可以抛出UnsupportedOperationException类的实例。例如,当您试图将一个对象添加到一个不可变的(不可修改的)集合中时,add()抛出UnsupportedOperationException(这将在本章后面讨论)。

其次,Collection的一些方法可以抛出ClassCastException类的实例。例如,当您试图从键为String s 的基于树的映射中删除一个条目(也称为映射)时,remove()抛出ClassCastException,但是指定了一个非String键。

最后,Collectionadd()addAll()方法会在要添加的元素的某个属性(属性)阻止它被添加到这个集合时抛出IllegalArgumentException实例。例如,第三方集合类的add()addAll()方法可能会在检测到负的Integer值时抛出这个异常。

images 注意也许你想知道为什么remove()被声明为接受任何Object参数,而不是只接受那些类型是集合的对象。换句话说,为什么remove()没有被宣布为boolean remove(E e)?此外,为什么不使用类型为Collection<? extends E>的参数声明containsAll()removeAll()retainAll(),以确保集合参数只包含与调用这些方法的集合类型相同的元素?这些问题的答案是需要保持向后兼容性。集合框架是在 Java 5 及其泛型引入之前引入的。为了让版本 5 之前编写的遗留代码继续编译,这四个方法被声明为具有较弱的类型约束。

迭代器和增强的 For 语句

通过扩展Iterable , Collection继承了该接口的iterator()方法,这使得迭代集合成为可能。iterator()返回一个类的实例,该类实现了Iterator接口,其泛型类型表示为Iterator<E>,并声明了以下三个方法:

  • 当此Iterator实例有更多元素要返回时,boolean hasNext()返回 true 否则,此方法返回 false。
  • E next()返回集合中与这个Iterator实例相关联的下一个元素,或者当没有更多元素要返回时抛出java.util.NoSuchElementException
  • void remove()从与这个Iterator实例相关的集合中删除next()返回的最后一个元素。每次next()调用只能调用一次该方法。当底层集合在迭代过程中被修改时,除了调用remove()之外,Iterator实例的行为是不确定的。当这个Iterator不支持这个方法时,这个方法抛出UnsupportedOperationException,当remove()已经被调用而没有先前对next()的调用时,或者当多个remove()调用发生而没有中间的next()调用时,这个方法抛出IllegalStateException

以下示例向您展示了如何在调用iterator()返回Iterator实例后迭代集合:

Collection<String> col = ... // This code does not compile because of the “...”.
// Add elements to col.
Iterator iter = col.iterator();
while (iter.hasNext())
   System.out.println(iter.next());

while 循环反复调用迭代器的hasNext()方法来确定迭代是否应该继续,并且(如果应该继续)调用next()方法来返回相关集合中的下一个元素。

因为这种习惯用法很常用,所以 Java 5 在 for 语句中引入了语法糖来简化这种习惯用法的迭代。这种糖使该语句看起来像在 Perl 等语言中发现的 foreach 语句,并在下面简化的上一个示例中显示出来:

Collection<String> col = ... // This code does not compile because of the “...”.
// Add elements to col.
for (String s: col)
   System.out.println(s);

这个 sugar 隐藏了col.iterator(),一个方法调用返回一个Iterator实例来迭代col的元素。它还隐藏了对这个实例上的IteratorhasNext()next()方法的调用。您将这个 sugar 解释如下:“对于col中的每个String对象,在循环迭代开始时将这个对象分配给s。”

images 注意增强的 for 语句在数组上下文中也很有用,它隐藏了数组索引变量。考虑以下示例:

String[] verbs = { "run", "walk", "jump" };
for (String verb: verbs)
   System.out.println(verb);

本例读作“对于verbs数组中的每个String对象,在循环迭代开始时将该对象分配给verb”,相当于下面的例子:

String[] verbs = { "run", "walk", "jump" };
for (int i = 0; i < verbs.length; i++)
   System.out.println(verbs[i]);

增强的 for 语句的局限性在于,在需要访问迭代器来从集合中移除元素的情况下,不能使用该语句。此外,在遍历期间必须替换集合/数组中的元素的情况下,它是不可用的,在必须并行迭代多个集合或数组的情况下,它也是不可用的。

images 提示为了让你的类支持增强的 for 语句,设计这些类来实现java.lang.Iterable接口。

汽车尾气排放与消毒

认为 Java 应该只支持引用类型的开发人员抱怨过 Java 对基本类型的支持。Java 类型系统二分法的一个明显体现是集合框架:可以在集合中存储对象,但不能存储基于类型的原始值。

虽然您不能在集合中直接存储基于基元类型的值,但是您可以通过首先将该值包装在从基元类型包装类(参见第四章)如Integer创建的对象中,然后将该基元类型包装类实例存储在集合中来间接存储该值——参见以下示例:

Collection<Integer> col = ...; // This code does not compile because of the “...”.
int x = 27;
col.add(new Integer(x)); // Indirectly store int value 27 via an Integer object.

反过来的情况也是繁琐的。当你想从col中检索int时,你必须调用IntegerintValue()方法(如果你还记得的话,它是从Integerjava.lang.Number超类继承而来的)。继续这个例子,您可以指定int y = col.iterator().next().intValue();将存储的 32 位整数分配给y

为了减轻这种乏味,Java 5 引入了自动装箱和取消装箱,这是一对互补的基于糖的语法语言特性,使原始值看起来更像对象。(这个“花招”并不完整,因为您不能指定像27.doubleValue()这样的表达式。)

自动装箱自动装箱(包装)每当指定了原始类型但需要引用时,在适当的原始类型包装类的对象中的原始值。例如,您可以将示例的第三行改为col.add(x);,并将编译器框x转换成一个Integer对象。

自动取消装箱**取消装箱(打开包装)无论何时指定了引用但需要原始类型时,都会从其包装对象中取消原始值。例如,您可以指定int y = col.iterator().next();并让编译器在赋值之前将返回的Integer对象解装箱为int值 27。

虽然引入自动装箱和取消装箱是为了简化在集合上下文中使用基元值,但是这些语言特性也可以在其他上下文中使用,这种任意使用可能会导致一个问题,如果不了解幕后发生的事情,就很难理解这个问题。考虑以下示例:

Integer i1 = 127;
Integer i2 = 127;
System.out.println(i1 == i2); // Output: true
System.out.println(i1 < i2); // Output: false
System.out.println(i1 > i2); // Output: false
System.out.println(i1+i2); // Output: 254
i1 = 30000;
i2 = 30000;
System.out.println(i1 == i2); // Output: false
System.out.println(i1 < i2); // Output: false
System.out.println(i1 > i2); // Output: false
i2 = 30001;
System.out.println(i1 < i2); // Output: true
System.out.println(i1+i2); // Output: 60001

除了一个例外,这个例子的输出和预期的一样。例外是i1 == i2比较,其中i1i2都包含 30000。i1i2都包含 127 的情况下,i1 == i2返回假,而不是返回真。是什么导致了这个问题?

检查生成的代码,你会发现Integer i1 = 127;被转换为Integer i1 = Integer.valueOf(127);Integer i2 = 127;被转换为Integer i2 = Integer.valueOf(127);。根据valueOf()的 Java 文档,这种方法利用缓存来提高性能。

images valueOf()也用于向集合添加原始值。例如,col.add(27)转换为col.add(Integer.valueOf(27))

Integer在一个小范围的值上维护唯一的Integer对象的内部缓存。此范围的下限是-128,上限默认为 127。然而,你可以通过给系统属性java.lang.Integer.IntegerCache.high分配一个不同的值来改变上限(通过java.lang.System类的String setProperty(String prop, String value)方法——我在第四章的中演示了这个方法的getProperty())。

images 注意ByteLongShort中的每一个还分别维护唯一的ByteLongShort对象的内部缓存。

由于缓存,每个Integer.valueOf(127)调用返回相同的Integer对象引用,这就是为什么i1 == i2(比较引用)评估为真。因为 30000 位于默认范围之外,所以每个Integer.valueOf(30000)调用都返回一个对新的Integer对象的引用,这就是为什么i1 == i2评估为 false。

==!=不同,它们在比较之前不会对装箱后的值进行拆箱,像<>+这样的操作符在执行它们的操作之前会对这些值进行拆箱。结果,i1 < i2转换为i1.intValue() < i2.intValue()i1+i2转换为i1.intValue()+i2.intValue()

images 注意不要假设自动装箱和取消装箱是在==!=操作符的上下文中使用的。

列表

一个列表是一个有序集合,也称为序列。元素可以通过整数索引存储在特定的位置,也可以从特定的位置访问。其中一些元素可能是重复的或空的(当列表的实现允许空元素时)。列表由List接口描述,其通用类型是List<E>

List扩展了Collection并重新声明了它继承的方法,部分是为了方便。它还重新声明了iterator()add()remove()equals()hashCode(),以便在合同上附加额外条件。例如,Listadd()的约定指定它将一个元素追加到列表的末尾,而不是将该元素添加到集合中。

List还声明了表 5-2 的列表特定方法。

image

image

image

表 5-2 引用了ListIterator接口,它比它的Iterator超级接口更加灵活,因为ListIterator提供了在任意方向上迭代列表、在迭代过程中修改列表以及获取迭代器在列表中的当前位置的方法。

images 注意ArrayListLinkedList List实现类中的iterator()listIterator()方法返回的IteratorListIterator实例是快速失效:在迭代器创建后,当列表被结构性修改(例如,通过调用实现的add()方法添加新元素)时,除了通过迭代器自己的add()remove()方法,迭代器抛出ConcurrentModificationException。因此,面对并发修改,迭代器会快速而干净地失败,而不是冒着在未来某个时间出现任意、不确定行为的风险。

ListIterator声明以下方法:

  • void add(E e)e插入到被迭代的列表中。这个元素被插入到下一个由next()返回的元素(如果有的话)之前,以及下一个由previous()返回的元素(如果有的话)之后。当这个列表迭代器不支持add()时,这个方法抛出UnsupportedOperationException,当e的类不适合这个列表时抛出ClassCastException,当e的某个属性阻止它被添加到这个列表时抛出IllegalArgumentException
  • boolean hasNext()正向遍历列表时,当该列表迭代器有更多元素时,返回 true。
  • boolean hasPrevious()当这个列表迭代器在反向遍历列表时有更多元素时返回 true。
  • E next()返回列表中的下一个元素并提升光标位置。当没有下一个元素时,这个方法抛出NoSuchElementException
  • int nextIndex()返回对next()的后续调用将返回的元素的索引,或者当在列表末尾时返回列表的大小。
  • 返回列表中的前一个元素,并向后移动光标位置。当没有前一个元素时,这个方法抛出NoSuchElementException
  • int previousIndex()返回对previous()的后续调用将返回的元素的索引,或者在列表开始时返回-1。
  • void remove()从列表中删除由next()previous()返回的最后一个元素。每次呼叫next()previous()只能呼叫一次。此外,只有在最后一次调用next()previous()后没有调用add()时,才能进行。当这个列表迭代器不支持remove()时,该方法抛出UnsupportedOperationException,当next()previous()都没有被调用,或者在最后一次调用next()previous()后已经调用了remove()add()时,该方法抛出IllegalStateException
  • void set(E e)用元素e替换next()previous()返回的最后一个元素。只有在最后一次调用next()previous()后,既没有调用remove()也没有调用add()时,才能进行此次调用。当这个列表迭代器不支持set()时,这个方法抛出UnsupportedOperationException,当e的类不适合这个列表时,抛出IllegalArgumentException,当e的某个属性阻止它被添加到这个列表时,抛出IllegalStateException,当next()previous()都没有被调用,或者在最后一次调用next()previous()之后已经调用了remove()add()时,抛出IllegalStateException

一个ListIterator实例没有当前元素的概念。相反,它有一个用于浏览列表的光标的概念。nextIndex()previousIndex()方法返回光标位置,它总是位于调用previous()返回的元素和调用next()返回的元素之间。长度为 n 的列表的列表迭代器有 n +1 个可能的光标位置,如每个脱字符号(^)所示,如下所示:

                     Element(0)   Element(1)   Element(2)   ... Element(n-1)
cursor positions:  ^            ^            ^            ^                  ^

remove()set()方法不是根据光标位置定义的;它们被定义为对调用next()previous()返回的最后一个元素进行操作。

images 注意只要小心,可以混合调用next()previous()。请记住,对previous()的第一次调用返回与对next()的最后一次调用相同的元素。此外,在对previous()的一系列调用之后,对next()的第一次调用返回与对previous()的最后一次调用相同的元素。

表 5-2 对subList()方法的描述指的是一个视图的概念,它是一个由另一个列表支持的列表。对视图所做的更改会反映在此支持列表中。视图可以覆盖整个列表,或者,正如subList()的名字所暗示的,只覆盖列表的一部分。

subList()方法对于以紧凑的方式在列表上执行范围视图操作很有用。例如,list.subList(fromIndex, toIndex).clear();list中删除一系列元素,其中第一个元素位于fromIndex,最后一个元素位于toIndex-1

images 注意当后备列表发生变化时,视图的含义变得不明确。因此,只要需要在后备列表上执行一系列范围操作,就应该临时使用subList()

阵列列表

ArrayList类提供了一个基于内部数组的列表实现(参见第一章和第二章)。因此,对列表元素的访问很快。但是,因为必须移动元素以打开插入空间或在删除后关闭空间,所以元素的插入和删除很慢。

ArrayList提供三个构造函数:

  • ArrayList()创建一个空数组列表,初始容量(存储空间)为十个元素。一旦达到这个容量,就会创建一个更大的数组,将当前数组中的元素复制到这个更大的数组中,这个更大的数组就成为新的当前数组。随着更多的元素被添加到数组列表中,这个过程会重复进行。
  • ArrayList(Collection<? extends E> c)创建一个数组列表,按照c的迭代器返回的顺序包含c的元素。当c包含空引用时,抛出NullPointerException
  • ArrayList(int initialCapacity)创建一个初始容量为initialCapacity个元素的空数组列表。当initialCapacity为负时,抛出IllegalArgumentException

清单 5-1 展示了一个数组列表。

清单 5-1。基于数组列表的演示

`import java.util.ArrayList;
import java.util.List;

class ArrayListDemo
{
   public static void main(String[] args)
   {
      List ls = new ArrayList<>();
      String[] weekDays = {"sun", "mon", "tue", "wed", "thu", "fri", "sat"};
      for (String weekDay: weekDays)
         ls.add(weekDay);
      dump("ls:", ls);
      ls.set(ls.indexOf("wed"), "wednesday");
      dump("ls:", ls);       ls.remove(ls.lastIndexOf("fri"));
      dump("ls:", ls);
   }
   static void dump(String title, List ls)
   {
      System.out.print(title+" ");
      for (String s: ls)
         System.out.print(s+" ");
      System.out.println();
   }
}`

List<String> ls = new ArrayList<>();任务揭示了一些需要注意的事项:

  • 我已经将变量ls声明为List<String>接口类型,并给这个变量分配了一个对实现这个接口的ArrayList类实例的引用。使用集合框架时,通常的做法是将变量声明为接口类型。当您需要使用不同的实现类时,这样做可以消除大量的代码更改;比如List<String> ls = new LinkedList<>();。查看第二章的“为什么使用接口?”一节,以获得关于这一实践的更多信息。
  • 菱形操作符<>(Java 7 中新增的)通过强制编译器推断泛型类构造函数的实际类型参数来减少冗长性。如果没有这个操作符,我将需要指定String作为传递给ArrayList<E>的实际类型参数,导致更长的List<String> ls = new ArrayList<String>();而不是更短的List<String> ls = new ArrayList<>();。(我并没有把钻石运算符当做真正的运算符,这也是我没有把它包含在第一章的运算符表— 表 1-3 中的原因。)

dump()方法的增强 for 语句在幕后使用了iterator()hasNext()next()

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

ls: Sun Mon Tue Wed Thu Fri Sat
ls: Sun Mon Tue Wednesday Thu Fri Sat
ls: Sun Mon Tue Wednesday Thu Sat
链接列表

LinkedList类提供了一个基于链接节点的列表实现。因为必须遍历链接,所以对列表元素的访问很慢。但是,因为只需要更改节点引用,所以元素的插入和删除很快。(我将在本章的后面向您介绍节点。)

LinkedList提供两个构造函数:

  • 创建一个空的链表。
  • LinkedList(Collection<? extends E> c)创建一个链表,包含c的元素,按照c的迭代器返回的顺序排列。当c包含空引用时,抛出NullPointerException

清单 5-2 展示了一个链表。

清单 5-2。节点链表的演示

import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;

class LinkedListDemo
{
   public static void main(String[] args)
   {
      List<String> ls = new LinkedList<>();
      String[] weekDays = {"sun", "mon", "tue", "wed", "thu", "fri", "sat"};
      for (String weekDay: weekDays)
         ls.add(weekDay);
      dump("ls:", ls);
      ls.add(1, "sunday");
      ls.add(3, "monday");
      ls.add(5, "tuesday");
      ls.add(7, "wednesday");
      ls.add(9, "thursday");
      ls.add(11, "friday");
      ls.add(13, "saturday");
      dump("ls:", ls);
      ListIterator<String> li = ls.listIterator(ls.size());
      while (li.hasPrevious())
         System.out.print(li.previous()+" ");
      System.out.println();
   }
   static void dump(String title, List<String> ls)
   {
      System.out.print(title+" ");
      for (String s: ls)
         System.out.print(s+" ");
      System.out.println();
   }
}

这个应用演示了当向列表中添加更长的工作日名称时,每个连续的add()方法调用必须将其索引增加 2,以考虑之前添加的元素。它还向您展示了如何以相反的顺序输出一个列表:返回一个列表迭代器,它的光标经过列表的末尾被初始化,并重复调用previous()

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

ls: Sun Mon Tue Wed Thu Fri Sat
ls: Sun Sunday Mon Monday Tue Tuesday Wed Wednesday Thu Thursday Fri Friday Sat Saturday
Saturday Sat Friday Fri Thursday Thu Wednesday Wed Tuesday Tue Monday Mon Sunday Sun

设定

一个集合是一个不包含重复元素的集合。换句话说,一个集合不包含元素对 e1e2 使得*e1*.equals(*e2*)返回真。此外,一个集合最多可以包含一个空元素。集合由Set接口描述,其类属类型为Set<E>

为了方便起见, Set扩展了Collection并重新声明了其继承的方法,同时也为add()equals()hashCode()的契约添加了规定,以解决它们在设定的上下文中的行为。另外,Set的文档声明所有实现类的构造函数必须创建不包含重复元素的集合。

Set不引入新方法。

TreeSet

TreeSet类提供了一个基于树数据结构的 set 实现。因此,元素按排序顺序存储。然而,访问这些元素比使用其他Set实现(没有排序)要慢一些,因为必须遍历链接。

images 查看维基百科的“树(数据结构)”词条([en.wikipedia.org/wiki/Tree_(data_structure)](http://en.wikipedia.org/wiki/Tree_(data_structure)))了解树。

TreeSet提供四个构造函数:

  • 创建一个新的空树集合,根据其元素的自然排序进行排序。插入到集合中的所有元素都必须实现Comparable接口。
  • TreeSet(Collection<? extends E> c)创建一个包含c元素的新树集合,根据其元素的自然排序进行排序。插入到新集合中的所有元素都必须实现Comparable接口。当c的元素没有实现Comparable或者不能相互比较时,这个构造函数抛出ClassCastException,当c包含空引用时,抛出NullPointerException
  • TreeSet(Comparator<? super E> comparator)创建一个新的空树集合,根据指定的comparator进行排序。将null传递给comparator意味着将使用自然排序。
  • TreeSet(SortedSet<E> s)创建一个新的树集合,包含与s相同的元素并使用相同的排序。(我将在本章后面讨论有序集合。)当s包含空引用时,这个构造函数抛出NullPointerException

清单 5-3 展示了一个树集合。

清单 5-3。一个树集合的演示,其中String个元素按照它们的自然顺序排序

`import java.util.Set;
import java.util.TreeSet;

class TreeSetDemo
{
   public static void main(String[] args)
   {       Set ss = new TreeSet<>();
      String[] fruits = {"apples", "pears", "grapes", "bananas", "kiwis"};
      for (String fruit: fruits)
         ss.add(fruit);
      dump("ss:", ss);
   }
   static void dump(String title, Set ss)
   {
      System.out.print(title+" ");
      for (String s: ss)
         System.out.print(s+" ");
      System.out.println();
   }
}`

因为String实现了Comparable,所以这个应用使用TreeSet()构造函数将fruits数组的内容插入到集合中是合法的。

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

ss: apples bananas grapes kiwis pears
哈希集

HashSet类提供了一个由散列表数据结构支持的 set 实现(实现为一个HashMap实例,稍后讨论,它提供了一种快速确定元素是否已经存储在该结构中的方法)。虽然这个类没有为它的元素提供排序保证,但是HashSetTreeSet要快得多。此外,HashSet允许将空引用存储在其实例中。

images 查看维基百科的“哈希表”条目([en.wikipedia.org/wiki/Hash_table](http://en.wikipedia.org/wiki/Hash_table))了解哈希表。

HashSet提供四个构造函数:

  • HashSet()创建一个新的空 hashset,其中 backing HashMap实例的初始容量为 16,加载因子为 0.75。当我在本章后面讨论HashMap的时候,你会知道这些项目的意思。
  • HashSet(Collection<? extends E> c)创建一个包含c元素的新 hashset。背衬HashMap的初始容量足以容纳c的元件,装载系数为 0.75。当c包含空引用时,这个构造函数抛出NullPointerException
  • HashSet(int initialCapacity)创建一个新的空 hashset,其中后台HashMap实例的容量由initialCapacity指定,负载系数为 0.75。当initialCapacity的值小于 0 时,该构造函数抛出IllegalArgumentException
  • HashSet(int initialCapacity, float loadFactor)创建一个新的空哈希表,其中后台HashMap实例具有由initialCapacity指定的容量和由loadFactor指定的加载因子。当initialCapacity小于 0 或者loadFactor小于等于 0 时,该构造函数抛出IllegalArgumentException

清单 5-4 展示了一个 hashset。

清单 5-4。一个散列表的演示,其中String个元素是无序的

import java.util.HashSet;
import java.util.Set;

class HashSetDemo
{
   public static void main(String[] args)
   {
      Set<String> ss = new HashSet<>();
      String[] fruits = {"apples", "pears", "grapes", "bananas", "kiwis",
                         "pears", null};
      for (String fruit: fruits)
         ss.add(fruit);
      dump("ss:", ss);
   }
   static void dump(String title, Set<String> ss)
   {
      System.out.print(title+" ");
      for (String s: ss)
         System.out.print(s+" ");
      System.out.println();
   }
}

在清单 5-3 的TreeSetDemo应用中,我没有将null添加到fruits数组中,因为TreeSet在检测到试图添加该元素时会抛出NullPointerException。相反,HashSet允许添加null,这就是为什么清单 5-4 在HashSetDemofruits数组中包含了null

当您运行此应用时,它会生成如下所示的无序输出:

ss: null grapes bananas kiwis pears apples

假设您想将类的实例添加到一个 hashset 中。和String一样,你的类必须覆盖equals()hashCode();否则,可能会在 hashset 中存储重复的类实例。例如,清单 5-5 给出了一个应用的源代码,这个应用的Planet类覆盖了equals(),但是没有覆盖hashCode()

清单 5-5。一个自定义Planet类不可覆盖hashCode()

`import java.util.HashSet;
import java.util.Set;

class CustomClassAndHashSet
{
   public static void main(String[] args)    {
      Set sp = new HashSet<>();
      sp.add(new Planet("mercury"));
      sp.add(new Planet("venus"));
      sp.add(new Planet("earth"));
      sp.add(new Planet("mars"));
      sp.add(new Planet("jupiter"));
      sp.add(new Planet("saturn"));
      sp.add(new Planet("uranus"));
      sp.add(new Planet("neptune"));
      sp.add(new Planet("fomalhaut b"));
      Planet p1 = new Planet("51 pegasi b");
      sp.add(p1);
      Planet p2 = new Planet("51 pegasi b");
      sp.add(p2);
      System.out.println(p1.equals(p2));
      System.out.println(sp);
   }
}
class Planet
{
   private String name;
   Planet(String name)
   {
      this.name = name;
   }
   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Planet))
         return false;
      Planet p = (Planet) o;
      return p.name.equals(name);
   }
   String getName()
   {
      return name;
   }
   @Override
   public String toString()
   {
      return name;
   }
}`

清单 5-5 的Planet类声明了一个String类型的name字段。虽然用单个String字段声明Planet可能看起来毫无意义,因为我可以重构这个清单来删除Planet并使用String,但我可能希望将来在Planet中引入额外的字段(也许是为了存储行星的质量和其他特征)。

当您运行此应用时,它会生成如下所示的无序输出:

true Venus, Fomalhaut b, Uranus, Mars, Neptune, Jupiter, Earth, Mercury, Saturn, 51 Pegasi![image b, 51 Pegasi b]

这个输出揭示了 hashset 中的两个51 Pegasi b元素。虽然从重写equals()方法的角度来看,这些元素是相等的(第一个输出行true证明了这一点),但是重写equals()并不足以避免在 hashset 中存储重复的元素:还必须重写hashCode()

覆盖清单 5-5 的Planet类中的hashCode()的最简单方法是让覆盖方法调用name字段的hashCode()方法并返回其值。(这种技术只适用于单个引用字段的类提供有效的hashCode()方法的类。)清单 5-6 展示了这个覆盖的hashCode()方法。

清单 5-6。一个自定义Planet类覆盖hashCode()

`import java.util.HashSet;
import java.util.Set;

class CustomClassAndHashSet
{
   public static void main(String[] args)
   {
      Set sp = new HashSet<>();
      sp.add(new Planet("mercury"));
      sp.add(new Planet("venus"));
      sp.add(new Planet("earth"));
      sp.add(new Planet("mars"));
      sp.add(new Planet("jupiter"));
      sp.add(new Planet("saturn"));
      sp.add(new Planet("uranus"));
      sp.add(new Planet("neptune"));
      sp.add(new Planet("fomalhaut b"));
      Planet p1 = new Planet("51 pegasi b");
      sp.add(p1);
      Planet p2 = new Planet("51 pegasi b");
      sp.add(p2);
      System.out.println(p1.equals(p2));
      System.out.println(sp);
   }
}
class Planet
{
   private String name;
   Planet(String name)
   {
      this.name = name;
   }
   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Planet))
         return false;
      Planet p = (Planet) o;       return p.name.equals(name);
   }
   String getName()
   {
      return name;
   }
   @Override
   public int hashCode()
   {
      return name.hashCode();
   }
   @Override
   public String toString()
   {
      return name;
   }
}`

编译清单 5-6 ( javac CustomClassAndHashSet.java)并运行应用(java CustomClassAndHashSet)。您将观察到没有重复元素的输出(类似于下面所示):

true
Saturn, Earth, Uranus, Fomalhaut b, 51 Pegasi b, Venus, Jupiter, Mercury, Mars,![image Neptune]

images 注意 LinkedHashSetHashSet的子类,它使用链表来存储其元素。因此,LinkedHashSet的迭代器按照元素被插入的顺序返回元素。例如,如果清单 5-4 中的指定了Set<String> ss = new LinkedHashSet<>();,那么应用的输出将会是ss: apples pears grapes bananas kiwis null。此外,LinkedHashSet提供比HashSet更慢的性能和比TreeSet更快的性能。

列举集

第三章向你介绍了传统的枚举类型和它们的枚举替换。(enum 是通过保留字enum表示的枚举类型。)下面的示例演示了传统的枚举类型:

static final int SUNDAY = 1;
static final int MONDAY = 2;
static final int TUESDAY = 4;
static final int WEDNESDAY = 8;
static final int THURSDAY = 16;
static final int FRIDAY = 32;
static final int SATURDAY = 64;

虽然 enum 比传统的枚举类型有很多优点,但是在将常量组合成一个集合时,传统的枚举类型使用起来不那么笨拙;比如static final int DAYS_OFF = SUNDAY | MONDAY;

DAYS_OFF是基于整数的固定长度的比特集的一个例子,它是一个比特集,其中当该比特被设置为 1 时,每个比特指示其相关联的成员属于该集合,而当该比特被设置为 0 时,该成员不在该集合中。

images 注意一个基于int的位集不能包含超过 32 个成员,因为int的大小是 32 位。类似地,基于long的位集不能包含超过 64 个成员,因为long的大小是 64 位。

这个位集是通过按位异或运算符(|)对传统枚举类型的整数常量进行按位异或运算而形成的:您也可以使用+。每个常数必须是 2 的唯一幂(从 1 开始),因为否则就不可能区分这个位集的成员。

若要确定某个常数是否属于位集,请创建一个包含位 AND 运算符(&)的表达式。例如,((DAYS_OFF&MONDAY) == MONDAY)DAYS_OFF (3)与MONDAY (2)进行按位与运算,结果为 2。这个值通过==MONDAY (2)进行比较,表达式的结果为真:MONDAYDAYS_OFF位集的成员。

通过实例化一个适当的Set实现类并多次调用add()方法来存储集合中的常量,您可以用 enum 完成相同的任务。清单 5-7 展示了这个更尴尬的选择。

清单 5-7。创造了相当于DAYS_OFFSet

import java.util.Set;
import java.util.TreeSet;

enum Weekday
{
   SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY
}
class DaysOff
{
   public static void main(String[] args)
   {
      Set<Weekday> daysOff = new TreeSet<>();
      daysOff.add(Weekday.SUNDAY);
      daysOff.add(Weekday.MONDAY);
      System.out.println(daysOff);
   }
}

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

[SUNDAY, MONDAY]

images 注意存储在树集中的是常量的序数,而不是它们的名字,这就是为什么名字看起来是无序的,即使常量是按照它们序数的排序顺序存储的。

除了比位集更难使用(也更冗长)之外,Set替代方案需要更多的内存来存储每个常量,而且速度也不够快。因为这些问题,EnumSet被引入。

EnumSet类提供了一个基于位集的Set实现。它的元素是常量,必须来自同一个枚举,该枚举是在创建枚举集时指定的。不允许空元素;任何存储空元素的尝试都会导致抛出NullPointerException

清单 5-8 演示了EnumSet

清单 5-8。创造了相当于DAYS_OFFEnumSet

import java.util.EnumSet;
import java.util.Iterator;
import java.util.Set;

enum Weekday
{
   SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY
}
class EnumSetDemo
{
   public static void main(String[] args)
   {
      Set<Weekday> daysOff = EnumSet.of(Weekday.SUNDAY, Weekday.MONDAY);
      Iterator<Weekday> iter = daysOff.iterator();
      while (iter.hasNext())
         System.out.println(iter.next());
   }
}

EnumSet,泛型类型为EnumSet<E extends Enum<E>>,提供了各种类方法,方便构造枚举集。例如,<E extends Enum<E>> EnumSet<E> of(E e1, E e2)返回一个由元素e1e2组成的EnumSet实例。在这个例子中,那些元素是Weekday.SUNDAYWeekday.MONDAY

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

SUNDAY
MONDAY

images 注意除了提供几个重载的of()方法,EnumSet还提供了其他方便创建枚举集的方法。例如,allOf()返回一个包含所有枚举常量的EnumSet实例,其中该方法的唯一参数是一个标识枚举的类文字:

Set<Weekday> allWeekDays = EnumSet.allOf(Weekday.class);

类似地,range()返回一个EnumSet实例,该实例包含一个枚举元素范围(该范围的限制由该方法的两个参数指定):

for (WeekDay wd : EnumSet.range(WeekDay.MONDAY, WeekDay.FRIDAY))
   System.out.println(wd);

排序集

TreeSet是有序集合的一个例子,它是一个以升序维护其元素的集合,根据它们的自然顺序或根据创建有序集合时提供的比较器进行排序。排序后的集合由SortedSet接口描述。

SortedSet,其通用类型为SortedSet<E>,扩展了Set。除了两个例外,它从Set继承的方法在排序集合上的行为与在其他集合上的行为相同:

  • iterator()返回的Iterator实例按照元素升序遍历排序后的集合。
  • toArray()返回的数组包含有序集合的元素。

images 注意虽然没有保证,但是集合框架中SortedSet实现的toString()方法(比如TreeSet)会返回一个包含所有有序集合元素的字符串。

SortedSet的文档要求一个实现必须提供我在讨论TreeSet时提出的四个标准构造函数。此外,该接口的实现必须实现表 5-3 中描述的方法。

image

image

headSet()subSet()tailSet()返回的基于集合的范围视图类似于从ListsubList()方法返回的基于列表的范围视图,除了基于集合的范围视图保持有效,甚至当支持排序的集合被修改时。因此,基于集合的范围视图可以使用很长一段时间。

images 注意与端点是后备列表中的元素的基于列表的范围视图不同,基于集合的范围视图的端点是元素空间中的绝对点,允许基于集合的范围视图充当集合的元素空间的一部分的窗口。对基于集合的范围视图所做的任何更改都被写回到后备排序集合,反之亦然。

headSet()subSet()tailSet()返回的每个范围视图是半开,因为它不包括它的高端点(headSet()subSet())或它的低端点(tailSet())。对于前两种方法,高端点由参数toElement指定;对于最后一种方法,低端点由参数fromElement指定。

images 注意你也可以把返回的范围视图看作是半封闭的,因为它只包括它的一个端点。

清单 5-9 展示了一个基于树集合的有序集合。

清单 5-9。一组经过排序的水果和蔬菜名称

`import java.util.SortedSet;
import java.util.TreeSet;

class SortedSetDemo
{
   public static void main(String[] args)
   {
      SortedSet sss = new TreeSet<>();
      String[] fruitAndVeg =
      {
         "apple", "potato", "turnip", "banana", "corn", "carrot", "cherry",
         "pear", "mango", "strawberry", "cucumber", "grape", "banana",
         "kiwi", "radish", "blueberry", "tomato", "onion", "raspberry",
         "lemon", "pepper", "squash", "melon", "zucchini", "peach", "plum",
         "turnip", "onion", "nectarine"
      };
      System.out.println("array size = "+fruitAndVeg.length);
      for (String fruitVeg: fruitAndVeg)
         sss.add(fruitVeg);
      dump("sss:", sss);
      System.out.println("sorted set size = "+sss.size());
      System.out.println("first element = "+sss.first());       System.out.println("last element = "+sss.last());
      System.out.println("comparator = "+sss.comparator());
      dump("hs:", sss.headSet("n"));
      dump("ts:", sss.tailSet("n"));
      System.out.println("count of p-named fruits & vegetables = "+
                         sss.subSet("p", "q").size());
      System.out.println("incorrect count of c-named fruits & vegetables = "+
                         sss.subSet("carrot", "cucumber").size());
      System.out.println("correct count of c-named fruits & vegetables = "+
                         sss.subSet("carrot", "cucumber\0").size());
   }
   static void dump(String title, SortedSet sss)
   {
      System.out.print(title+" ");
      for (String s: sss)
         System.out.print(s+" ");
      System.out.println();
   }
}`

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

Array size = 29
sss: apple banana blueberry carrot cherry corn cucumber grape kiwi lemon mango melon![image](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
nectarine onion peach pear pepper plum potato radish raspberry squash strawberry![image](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) tomato
turnip zucchini
Sorted set size = 26
First element = apple
Last element = zucchini
Comparator = null
hs: apple banana blueberry carrot cherry corn cucumber grape kiwi lemon mango melon![image](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
ts: nectarine onion peach pear pepper plum potato radish raspberry squash strawberry![image](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) tomato
turnip zucchini
Count of p-named fruits & vegetables = 5
Incorrect count of c-named fruits & vegetables = 3
Correct count of c-named fruits & vegetables = 4

这个输出显示,排序后的集合的大小小于数组的大小,因为集合不能包含重复的元素:重复的bananaturniponion元素没有存储在排序后的集合中。

comparator()方法返回 null,因为排序集不是用比较器创建的。相反,有序集合依赖于String元素的自然排序,以有序的顺序存储它们。

使用参数"n"调用headSet()tailSet()方法,以分别返回一组元素,这些元素的名称以严格小于n的字母和大于或等于n的字母开头。

最后,输出告诉您在向subSet()传递一个上限时必须小心。如您所见,ss.subSet("carrot", "cucumber")在返回的范围视图中不包括cucumber,因为cucumbersubSet()的高端点。

要将cucumber包含在范围视图中,需要形成一个闭合范围闭合区间(包含两个端点)。使用String对象,您可以通过将\0附加到字符串来完成这项任务。比如ss.subSet("carrot", "cucumber\0")包含cucumber,因为它小于cucumber\0

同样的技术可以应用于任何需要形成开放范围开放区间的地方(不包括端点)。例如,ss.subSet("carrot\0", "cucumber")不包括carrot,因为它小于carrot\0。此外,它不包括高端点cucumber

images 注意当你想为从你自己的类中创建的元素创建封闭和开放的范围时,你需要提供某种形式的predecessor()successor()方法来返回一个元素的前任和继任者。

在设计使用有序集合的类时,您需要非常小心。例如,当您计划将该类的实例存储在一个有序集合中时,该类必须实现Comparable,在该集合中,这些元素根据它们的自然顺序进行排序。考虑清单 5-10 中的。

清单 5-10。一个自定义的Employee类没有实现Comparable

import java.util.SortedSet;
import java.util.TreeSet;

class CustomClassAndSortedSet
{
   public static void main(String[] args)
   {
      SortedSet<Employee> sse = new TreeSet<>();
      sse.add(new Employee("sally doe"));
      sse.add(new Employee("bob doe")); // ClassCastException thrown here
      sse.add(new Employee("john doe"));
      System.out.println(sse);
   }
}
class Employee
{
   private String name;
   Employee(String name)
   {
      this.name = name;
   }
   @Override
   public String toString()
   {
      return name;
   }
}

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

Exception in thread "main" java.lang.ClassCastException: Employee cannot be cast to![image](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) java.lang.Comparable         at java.util.TreeMap.compare(TreeMap.java:1188)         at java.util.TreeMap.put(TreeMap.java:531)         at java.util.TreeSet.add(TreeSet.java:255)         at CustomClassAndSortedSet.main(CustomClassAndSortedSet.java:9)

在第二个add()方法调用期间抛出了ClassCastException实例,因为排序集实现(?? 的一个实例)无法调用第二个Employee元素的compareTo()方法,因为Employee没有实现Comparable

这个问题的解决方案是让这个类实现Comparable,这正是清单 5-11 中展示的。

清单 5-11。使Employee元素具有可比性

import java.util.SortedSet;
import java.util.TreeSet;

class CustomClassAndSortedSet
{
   public static void main(String[] args)
   {
      SortedSet<Employee> sse = new TreeSet<>();
      sse.add(new Employee("sally doe"));
      sse.add(new Employee("bob doe"));
      Employee e1 = new Employee("john doe");
      Employee e2 = new Employee("john doe");
      sse.add(e1);
      sse.add(e2);
      System.out.println(sse);
      System.out.println(e1.equals(e2));
   }
}
class Employee implements Comparable<Employee>
{
   private String name;
   Employee(String name)
   {
      this.name = name;
   }
   @Override
   public int compareTo(Employee e)
   {
      return name.compareTo(e.name);
   }
   @Override
   public String toString()
   {
      return name;
   }
}

清单 5-11 的main()方法与清单 5-10 的不同之处在于,它还创建了两个初始化为"john doe"Employee对象,将这些对象添加到排序集合中,并通过 equals()比较这些对象是否相等。此外,清单 5-11 声明Employee实现Comparable,将compareTo()方法引入Employee

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

[Bob Doe, John Doe, Sally Doe]
false

该输出显示只有一个"john doe" Employee对象存储在排序后的集合中。毕竟,一个集合不能包含重复的元素。然而,false值(由equals()比较得出)也表明排序后集合的自然排序与equals()不一致,这违反了SortedSet的约定:

如果有序集合要正确实现Set接口,有序集合维护的顺序(无论是否提供显式比较器)必须与equals()一致。这是因为Set接口是根据equals()操作定义的,但是一个有序集合使用它的compareTo()(或compare())方法执行所有元素比较,所以从有序集合的角度来看,被该方法视为相等的两个元素是相等的。

因为应用工作正常,为什么SortedSet的合同有关系?尽管契约似乎与SortedSetTreeSet实现无关,但在实现该接口的第三方类的上下文中可能会有关系。

清单 5-12 向您展示了如何纠正这个问题,并让Employee实例与一个有序集合的任何实现一起工作。

清单 5-12。符合合同的Employee

`import java.util.SortedSet;
import java.util.TreeSet;

class CustomClassAndSortedSet
{
   public static void main(String[] args)
   {
      SortedSet sse = new TreeSet<>();
      sse.add(new Employee("sally doe"));
      sse.add(new Employee("bob doe"));
      Employee e1 = new Employee("john doe");
      Employee e2 = new Employee("john doe");
      sse.add(e1);
      sse.add(e2);
      System.out.println(sse);
      System.out.println(e1.equals(e2));
   }
}
class Employee implements Comparable
{
   private String name;
   Employee(String name)
   {
      this.name = name;
   }
   @Override
   public int compareTo(Employee e)
   {       return name.compareTo(e.name);
   }
   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Employee))
         return false;
      Employee e = (Employee) o;
      return e.name.equals(name);
   }
   @Override
   public String toString()
   {
      return name;
   }
}`

清单 5-12 通过覆盖equals()纠正了SortedSet合同违反。运行生成的应用,您会看到输出的第一行是[Bob Doe, John Doe, Sally Doe],第二行是true:排序后的集合的自然排序现在与equals()一致。

images 注意尽管每当你重写equals()时重写hashCode()是很重要的,但是我没有重写清单 5-12 的Employee类中的hashCode()(尽管我重写了equals(),以强调基于树的排序集合忽略hashCode()

适航设置

TreeSet是可导航集合的一个例子,它是一个排序集合,可以以降序和升序迭代,并且可以报告给定搜索目标的最接近匹配。导航集由NavigableSet接口描述,其通用类型为NavigableSet<E>,扩展了SortedSet,在表 5-4 中有描述。

image

image

image

清单 5-13 展示了一个基于树集合的可导航集合。

清单 5-13。导航一组整数

`import java.util.Iterator;
import java.util.NavigableSet;
import java.util.TreeSet;

class NavigableSetDemo
{
   public static void main(String[] args)
   {
      NavigableSet ns = new TreeSet<>();
      int[] ints = { 82, -13, 4, 0, 11, -6, 9 };
      for (int i: ints)
         ns.add(i);
      System.out.print("ascending order: ");       Iterator iter = ns.iterator();
      while (iter.hasNext())
         System.out.print(iter.next()+" ");
      System.out.println();
      System.out.print("descending order: ");
      iter = ns.descendingIterator();
      while (iter.hasNext())
         System.out.print(iter.next()+" ");
      System.out.println("\n");
      outputClosestMatches(ns, 4);
      outputClosestMatches(ns.descendingSet(), 12);
   }
   static void outputClosestMatches(NavigableSet ns, int i)
   {
      System.out.println("element < "+i+" is "+ns.lower(i));
      System.out.println("element <= "+i+" is "+ns.floor(i));
      System.out.println("element > "+i+" is "+ns.higher(i));
      System.out.println("element >= "+i+" is "+ns.ceiling(i));
      System.out.println();
   }
}`

清单 5-13 创建一组可导航的Integer元素。它利用自动装箱来确保int被转换成Integers

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

Ascending order: -13 -6 0 4 9 11 82
Descending order: 82 11 9 4 0 -6 -13

Element < 4 is 0
Element <= 4 is 4
Element > 4 is 9
Element >= 4 is 4

Element < 12 is 82
Element <= 12 is 82
Element > 12 is 11
Element >= 12 is 11

Element开头的前四个输出行属于一个升序集合,其中匹配的元素(4)是该集合的成员。第二个四个Element前缀的行属于降序集合,其中匹配的元素(12)不是成员。

除了让您通过最接近匹配方法(ceiling()floor()higher()lower())方便地定位集合元素之外,NavigableSet还让您返回包含特定范围内所有元素的集合视图,如以下示例所示:

  • ns.subSet(-13, true, 9, true):返回从-139的所有元素。
  • ns.tailSet(-6, false):返回所有大于-6的元素。
  • ns.headSet(4, true):返回所有小于等于4的元素。

最后,您可以通过调用pollFirst()从集合中返回和移除第一个(最低的)元素,通过调用pollLast()返回和移除最后一个(最高的)元素。比如ns.pollFirst()移除并返回-13ns.pollLast()移除并返回-82

队列

一个队列是一个集合,其中的元素以特定的顺序存储和检索。大多数队列分为以下几类:

  • 先进先出(FIFO)队列:在队列的尾部插入元素,在队列的头部移除元素。
  • 后进先出(LIFO)队列:在队列的一端插入和移除元素,使得插入的最后一个元素是检索的第一个元素。这种队列表现为一个
  • 优先级队列:根据元素的自然排序,或者根据提供给队列实现的比较器来插入元素。

泛型类型为Queue<E>Queue扩展了Collection,重新声明了add()以调整其契约(如果可以在不违反容量限制的情况下立即将指定的元素插入到该队列中),并从Collection继承了其他方法。表 5-5 描述了add()和其他Queue的具体方法。

image

image

表 5-5 揭示了两组方法:一组中,一个方法(如add())在操作失败时抛出异常;在另一组中,一个方法(比如offer())在出现故障时返回一个特殊值(false 或 null)。返回特殊值的方法在容量受限的Queue实现环境中很有用,在这种环境中,失败是经常发生的事情。

images 注意在使用容量受限队列时,offer()方法通常比add()更好,因为offer()不会抛出IllegalStateException

Java 提供了许多Queue实现类,其中大多数类都是java.util.concurrent包的成员:LinkedBlockingQueueLinkedTransferQueueSynchronousQueue就是例子。相比之下,java.util包提供了LinkedListPriorityQueue作为它的Queue实现类。

images 注意许多Queue实现类不允许添加空元素。然而,有些类(比如LinkedList)允许空元素。您应该避免添加 null 元素,因为 null 被peek()poll()方法用作一个特殊的返回值来指示队列为空。

优先权队列

PriorityQueue类提供了一个优先级队列的实现,这是一个队列,它根据元素的自然顺序或队列实例化时提供的比较器对其元素进行排序。当依赖自然排序时,优先级队列不允许空元素,也不允许插入非Comparable对象。

优先级队列头部的元素是指定排序中最小的元素。如果多个元素并列为最小元素,则任意选择其中一个元素作为最小元素。类似地,优先级队列尾部的元素是最大的元素,当出现平局时任意选择。

优先级队列是无限的,但是有一个容量来控制用于存储优先级队列元素的内部数组的大小。容量值至少与队列的长度一样大,并且随着元素被添加到优先级队列中而自动增长。

PriorityQueue(其泛型类型为PriorityQueue<E>)提供了六个构造函数:

  • PriorityQueue()创建一个初始容量为 11 个元素的PriorityQueue实例,并根据元素的自然顺序对其进行排序。
  • PriorityQueue(Collection<? extends E> c)创建一个包含c元素的PriorityQueue实例。如果c是一个SortedSetPriorityQueue实例,这个优先级队列将按照相同的顺序进行排序。否则,该优先级队列将根据其元素的自然顺序进行排序。当c的元素不能按照优先级队列的顺序相互比较时,这个构造函数抛出ClassCastException,当c或它的任何元素包含空引用时,抛出NullPointerException
  • PriorityQueue(int initialCapacity)用指定的initialCapacity创建一个PriorityQueue实例,并根据元素的自然顺序对其进行排序。当initialCapacity小于 1 时,这个构造函数抛出IllegalArgumentException
  • PriorityQueue(int initialCapacity, Comparator<? super E> comparator)用指定的initialCapacity创建一个PriorityQueue实例,并根据指定的comparator对其元素进行排序。当comparator包含空引用时,使用自然排序。当initialCapacity小于 1 时,这个构造函数抛出IllegalArgumentException
  • PriorityQueue(PriorityQueue<? extends E> pq)创建一个包含pq元素的PriorityQueue实例。该优先级队列将按照与pq相同的顺序进行排序。当pq的元素不能按照pq的顺序相互比较时,这个构造函数抛出ClassCastException,当pq或其任何元素包含空引用时,抛出NullPointerException
  • PriorityQueue(SortedSet<? extends E> ss)创建一个包含ss元素的PriorityQueue实例。该优先级队列将按照与ss相同的顺序进行排序。当sortedSet的元素不能按照ss的顺序相互比较时,这个构造函数抛出ClassCastException,当sortedSet或其任何元素包含空引用时,抛出NullPointerException

清单 5-14 展示了一个优先级队列。

清单 5-14。向优先级队列添加随机生成的整数

`import java.util.PriorityQueue;
import java.util.Queue;

class PriorityQueueDemo
{
   public static void main(String[] args)
   {
      Queue qi = new PriorityQueue<>();
      for (int i = 0; i < 15; i++)          qi.add((int) (Math.random()*100));
      while (!qi.isEmpty())
         System.out.print(qi.poll()+" ");
      System.out.println();
   }
}`

创建优先级队列后,主线程向该队列添加 15 个随机生成的整数(范围从 0 到 99)。然后它进入一个 while 循环,重复轮询下一个元素的优先级队列,并输出该元素,直到队列为空。

当您运行这个应用时,它从左到右按数字升序输出一行 15 个整数。例如,我在一次运行中观察到以下输出:

11 21 29 35 40 53 66 70 72 75 80 83 87 88 89

因为当没有更多的元素时,poll()返回 null,我可以将这个循环编码如下:

Integer i;
while ((i = qi.poll()) != null)
   System.out.print(i+" ");

假设您想颠倒前一个应用的输出顺序,使最大的元素出现在左边,最小的元素出现在右边。如清单 5-15 所示,你可以通过向适当的PriorityQueue构造函数传递一个比较器来完成这个任务。

清单 5-15。使用带有优先级队列的比较器

import java.util.Comparator;
import java.util.PriorityQueue;
import java.util.Queue;

class PriorityQueueDemo
{
   final static int NELEM = 15;
   public static void main(String[] args)
   {
      Comparator<Integer> cmp;
      cmp = new Comparator<Integer>()
            {
               public int compare(Integer e1, Integer e2)
               {
                  return e2-e1;
               }
            };
      Queue<Integer> qi = new PriorityQueue<>(NELEM, cmp);
      for (int i = 0; i < NELEM; i++)
         qi.add((int) (Math.random()*100));
      while (!qi.isEmpty())
         System.out.print(qi.poll()+" ");
      System.out.println();
   }
}

清单 5-15 与清单 5-14 相似,但也有一些不同。首先,我声明了一个NELEM常量,这样我就可以通过在一个地方指定新值来轻松地改变优先级队列的初始容量和插入到优先级队列中的元素数量。

其次,清单 5-15 声明并实例化了一个实现Comparator的匿名类。它的compareTo()方法从元素e1中减去元素e2,以实现数字降序。编译器通过将e2-e1转换为e2.intValue()-e1.intValue()来处理取消e2e1装箱的任务。

最后,清单 5-15 将NELEM元素的初始容量和实例化的比较器传递给PriorityQueue(int initialCapacity, Comparator<? super E> comparator)构造函数。优先级队列将使用该比较器对这些元素进行排序。

运行这个应用,您将会看到一个由 15 个整数组成的输出行,按照从左到右的降序排列。例如,我观察到这样的输出行:

90 86 78 74 65 53 45 44 30 28 18 9 9 7 5

Deque

一个队列(发音为 deck)是一个双端队列,其中元素的插入或移除发生在其头部或尾部。Deques 可以用作队列或堆栈。

Deque,其泛型类型为Deque<E>,扩展了Queue,其中继承的add(E e)方法在队列尾部插入了e。表 5-6 描述了Deque的具体方法。

image

image

image

image

如表 5-6 所示,Deque声明了访问队列两端元素的方法。提供了插入、移除和检查元素的方法。这些方法中的每一个都以两种形式存在:一个在操作失败时抛出异常,另一个返回特殊值(null 或 false,取决于操作)。后一种形式的插入操作是专门为容量受限的Deque实现而设计的;在大多数实现中,插入操作不会失败。

图 5-2 展示了Deque的 Java 文档中的一个表格,该表格很好地总结了头部和尾部的插入、移除和检查方法的两种形式。

images

图 5-2。 Deque声明了十二种方法,用于插入、移除和检查队列头部或尾部的元素。

当一个队列用作队列时,会产生 FIFO(先进先出)行为。元素被添加到队列的末尾,并从开头移除。从Queue接口继承的方法完全等同于表 5-7 中所示的Deque方法。

image

最后,deques 也可以用作 LIFO(后进先出)堆栈。当一个队列被用作堆栈时,元素从队列的开始被推入和弹出。因为堆栈的push(e)方法等同于DequeaddFirst(e)方法,其pop()方法等同于DequeremoveFirst()方法,其peek()方法等同于DequepeekFirst()方法,Deque声明了E peek()E pop()void push(E e)面向堆栈的便利方法。

array deqo

ArrayDeque类提供了Deque接口的可调整大小的数组实现。它禁止将空元素添加到 deque 中,并且它的iterator()方法返回失败快速迭代器。

ArrayDeque提供三个构造函数:

  • 创建一个初始容量为 16 个元素的空数组列表。
  • ArrayDeque(Collection<? extends E> c)创建一个包含c元素的数组队列,按照c的迭代器返回的顺序排列。(由c的迭代器返回的第一个元素成为 deque 的第一个元素或前面。)NullPointerExceptionc包含空引用时抛出。
  • ArrayDeque(int numElements)创建一个空数组队列,其初始容量足以容纳numElements个元素。当传递给numElements的参数小于或等于零时,不会抛出异常。

清单 5-16 展示了一个数组队列。

清单 5-16。使用数组队列作为堆栈

import java.util.ArrayDeque;
import java.util.Deque;

class ArrayDequeDemo
{
   public static void main(String[] args)
   {
      Deque<String> stack = new ArrayDeque<>();
      String[] weekdays = { "Sunday", "Monday", "Tuesday", "Wednesday",
                            "Thursday", "Friday", "Saturday" };
      for (String weekday: weekdays)
         stack.push(weekday);
      while (stack.peek() != null)
         System.out.println(stack.pop());
   }
}

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

Saturday
Friday
Thursday
Wednesday
Tuesday
Monday
Sunday

地图

一个映射是一组键/值对(也称为条目)。因为关键字标识一个条目,所以映射不能包含重复的关键字。此外,每个键最多只能映射到一个值。地图由Map接口描述,该接口没有父接口,其泛型类型为Map<K,V> ( K是键的类型;V是值的类型)。

表 5-8 描述了Map的方法。

image

image

image

ListSetQueue不同,Map不延伸Collection。然而,通过调用MapkeySet()values()entrySet()方法,可以将映射作为Collection实例来查看,这些方法分别返回键的Set、值的Collection和键/值对条目的Set

images 注意values()方法返回Collection而不是Set,因为多个键可以映射到同一个值,然后values()会返回同一个值的多个副本。

这些方法返回的Collection视图(回想一下,a Set是一个Collection,因为Set扩展了Collection)提供了迭代 a Map的唯一方法。例如,假设您用三个Color常量、REDGREENBLUE声明了清单 5-17 的Color枚举。

清单 5-17。五彩缤纷的 enum

enum Color
{
   RED(255, 0, 0),
   GREEN(0, 255, 0),
   BLUE(0, 0, 255);
   private int r, g, b;
   private Color(int r, int g, int b)
   {
      this.r = r;
      this.g = g;
      this.b = b;
   }
   @Override
   public String toString()
   {
      return "r = "+r+", g = "+g+", b = "+b;
   }
}

以下示例声明了一个由String键和Color值组成的映射,向该映射添加了几个条目,并对键和值进行了迭代:

Map<String, Color> colorMap = ...; // ... represents creation of a Map implementation colorMap.put("red", Color.RED); colorMap.put("blue", Color.BLUE); colorMap.put("green", Color.GREEN); colorMap.put("RED", Color.RED); for (String colorKey: colorMap.keySet())    System.out.println(colorKey); Collection<Color> colorValues = colorMap.values(); for (Iterator<Color> it = colorValues.iterator(); it.hasNext();)    System.out.println(it.next());

当针对colorMap的 hashmap 实现(稍后讨论)运行这个示例时,您应该观察到类似如下的输出:

red
blue
green
RED
r = 255, g = 0, b = 0
r = 0, g = 0, b = 255
r = 0, g = 255, b = 0
r = 255, g = 0, b = 0

前四个输出行标识映射的键;接下来的四个输出行标识地图的值。

entrySet()方法返回一个Map.Entry对象的Set。这些对象中的每一个都将单个条目描述为一个键/值对,并且是实现Map.Entry接口的类的实例,其中EntryMap的嵌套接口。表 5-9 描述了Map.Entry的方法。

image

以下示例向您展示了如何迭代上一示例的地图条目:

for (Map.Entry<String, Color> colorEntry: colorMap.entrySet())
   System.out.println(colorEntry.getKey()+": "+colorEntry.getValue());

当针对前面提到的 hashmap 实现运行这个示例时,您会看到以下输出:

red: r = 255, g = 0, b = 0
blue: r = 0, g = 0, b = 255
green: r = 0, g = 255, b = 0
RED: r = 255, g = 0, b = 0
树图

TreeMap类提供了一个基于红黑树的 map 实现。因此,条目按其键的排序顺序存储。然而,访问这些条目比使用其他Map实现(没有排序)要慢一些,因为必须遍历链接。

images 查看维基百科的“红黑树”词条([en.wikipedia.org/wiki/Red-black_tree](http://en.wikipedia.org/wiki/Red-black_tree))了解红黑树。

TreeMap提供四个构造函数:

  • TreeMap()创建一个新的空的树形图,根据其关键字的自然顺序进行排序。所有插入到 map 中的键都必须实现Comparable接口。
  • TreeMap(Comparator<? super K> comparator)根据指定的comparator创建一个新的、空的树形图。将null传递给comparator意味着将使用自然排序。
  • TreeMap(Map<? extends K, ? extends V> m)创建一个包含m条目的新树形图,根据其关键字的自然顺序进行排序。所有插入新地图的键必须实现Comparable接口。当m的键没有实现Comparable或者不能相互比较时,这个构造函数抛出ClassCastException,当m包含空引用时,抛出NullPointerException
  • TreeMap(SortedMap<K, ? extends V> sm)创建一个新的树形图,包含与sm相同的条目并使用相同的排序。(我将在本章后面讨论排序地图。)当sm包含空引用时,这个构造函数抛出NullPointerException

清单 5-18 展示了一个树形图。

清单 5-18。根据基于String的关键字的自然排序对地图条目进行排序

import java.util.Map;
import java.util.TreeMap;

class TreeMapDemo
{
   public static void main(String[] args)
   {
      Map<String, Integer> msi = new TreeMap<>();
      String[] fruits = {"apples", "pears", "grapes", "bananas", "kiwis"};
      int[] quantities = {10, 15, 8, 17, 30};
      for (int i = 0; i < fruits.length; i++)
         msi.put(fruits[i], quantities[i]);
      for (Map.Entry<String, Integer> entry: msi.entrySet())
         System.out.println(entry.getKey()+": "+entry.getValue());
   }
}

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

apples: 10
bananas: 17
grapes: 8
kiwis: 30
pears: 15
哈希映射

HashMap类提供了一个基于哈希表数据结构的 map 实现。这个实现支持所有的Map操作,并允许空键和空值。它不保证条目的存储顺序。

哈希表在一个哈希函数的帮助下将键映射到整数值。Java 以ObjecthashCode()方法的形式提供这个函数,类覆盖这个函数以提供适当的哈希代码。

一个散列码标识哈希表的数组元素之一,它被称为。对于一些哈希表,桶可以存储与键相关联的值。图 5-3 说明了这种散列表。

images

图 5-3。一个简单的哈希表将键映射到存储与这些键相关的值的桶。

散列函数将Bob Doe散列到0,这标识了第一个桶。这个桶包含ACCTS,它是Bob Doe的员工类型。散列函数还将John DoeSally Doe散列到12(分别),它们的桶包含SALES

完美的散列函数将每个键散列成一个唯一的整数值。然而,这个理想很难实现。实际上,一些键将散列到相同的整数值。这种不唯一的映射被称为碰撞

为了解决冲突,大多数哈希表将条目的链表与桶相关联。桶不包含值,而是包含链表中第一个节点的地址,每个节点包含一个冲突条目。参见图 5-4 。

images

图 5-4。一个复杂的哈希表将键映射到存储链表引用的桶,链表的节点值是从相同的键散列而来的。

在哈希表中存储值时,哈希表使用哈希函数将关键字哈希到其哈希代码中,然后搜索适当的链表以查看是否存在具有匹配关键字的条目。如果有条目,它的值会用新值更新。否则,将创建一个新节点,用键和值填充,并附加到列表中。

当从哈希表中检索值时,哈希表使用哈希函数将键哈希到其哈希代码,然后搜索适当的链表以查看是否存在具有匹配键的条目。如果有条目,则返回其值。否则,哈希表可能会返回一个特殊值来指示没有条目,或者可能会引发异常。

桶的数量被称为哈希表的容量。存储条目的数量除以存储桶的数量的比率被称为哈希表的加载因子。选择正确的负载系数对于平衡性能和内存使用非常重要:

  • 当负载因子接近 1 时,冲突的概率和处理冲突的成本(通过搜索冗长的链表)会增加。
  • 当负载因子接近 0 时,散列表的大小随着存储桶数量的增加而增加,而搜索成本几乎没有提高。
  • 对于许多哈希表来说,0.75 的加载因子接近最佳值。这个值是HashMap的哈希表实现的默认值。

HashMap提供四个构造函数:

  • 创建一个新的空散列表,初始容量为 16,装载系数为 0.75。
  • HashMap(int initialCapacity)创建一个新的空散列表,其容量由initialCapacity指定,加载因子为 0.75。当initialCapacity的值小于 0 时,该构造函数抛出IllegalArgumentException
  • HashMap(int initialCapacity, float loadFactor)创建一个新的空散列表,其容量由initialCapacity指定,负载系数由loadFactor指定。当initialCapacity小于 0 或者loadFactor小于等于 0 时,该构造函数抛出IllegalArgumentException
  • HashMap(Map<? extends K, ? extends V> m)创建一个包含m条目的新散列表。当m包含空引用时,这个构造函数抛出NullPointerException

清单 5-19 展示了一个散列表。

清单 5-19。使用散列表计算命令行参数

`import java.util.HashMap;
import java.util.Map;

class HashMapDemo
{
   public static void main(String[] args)
   {
      Map<String, Integer> argMap = new HashMap<>();
      for (String arg: args)
      {
         Integer count = argMap.get(arg);          argMap.put(arg, (count == null) ? 1 : count+1);
      }
      System.out.println(argMap);
      System.out.println("Number of distinct arguments = "+argMap.size());
   }
}`

HashMapDemo创建一个由String键和Integer值组成的散列表。每个键都是传递给该应用的命令行参数之一,其值是该参数在命令行中出现的次数。

例如,java HashMapDemo how much wood could a woodchuck chuck if a woodchuck could chuck wood生成以下输出:

{wood=2, could=2, how=1, if=1, chuck=2, a=2, woodchuck=2, much=1}
Number of distinct arguments = 8

因为String类覆盖了equals()hashCode(),清单 5-19 可以使用String对象作为散列表中的键。当创建一个其实例将用作键的类时,必须确保重写这两个方法。

清单 5-6 向您展示了一个类的覆盖hashCode()方法可以调用一个引用字段的hashCode()方法并返回它的值,只要这个类声明一个引用字段(并且没有原始类型字段)。

更常见的情况是,类声明多个字段,并且需要更好地实现hashCode()方法。实现应该尝试生成最小化冲突的哈希代码。

没有关于如何最好地实现hashCode()的规则,各种算法(完成任务的方法)被创造出来。我最喜欢的算法出现在约书亚·布洛赫(Addison-Wesley,2008;ISBN: 0321356683)。

以下算法假设存在一个被称为 X 的任意类,该算法与 Bloch 的算法非常相似,但并不相同:

  1. int变量hashCode(名称任意)初始化为任意非零整数值,比如 19。该变量被初始化为非零值,以确保它考虑了散列码为零的任何初始字段。如果你将hashCode初始化为 0,最终的散列码将不会受到这些字段的影响,你将冒增加冲突的风险。
  2. 对于在 Xequals()方法中使用的每个字段f,计算f的哈希码,并将其分配给int变量hc,如下所示:
    1. 如果f是布尔类型,则计算hc = f?1:0
    2. 如果f是字节整数、字符、整数或短整数类型,则计算hc = (int) f。整数值是散列码。
    3. 如果f是长整型,则计算hc = (int) (f^(f>>>32))。该表达式将长整数的最低有效 32 位与其最高有效 32 位进行异或运算。
    4. 如果f是浮点类型,则计算hc = Float.floatToIntBits(f)。此方法考虑了+无穷大、-无穷大和 NaN。
    5. 如果f是双精度浮点类型,则计算long l = Double.doubleToLongBits(f); hc = (int) (l^(l>>>32))
    6. 如果f是空引用的引用字段,则计算hc = 0
    7. 如果f是一个非空引用的引用字段,如果 Xequals()方法通过递归调用equals()来比较字段(如清单 5-12 的Employee类),计算hc = f.hashCode()。然而,如果equals()使用更复杂的比较,创建字段的规范(最简单的可能)表示,并在这个表示上调用hashCode()
    8. 如果f是一个数组,通过递归地应用该算法并组合hc值,将每个元素视为一个单独的字段,如下一步所示。
  3. hchashCode组合如下:hashCode = hashCode*31+hc。将hashCode乘以31使得结果哈希值依赖于字段在类中出现的顺序,当一个类包含多个相似的字段(例如,几个int)时,这会提高哈希值。我选择 31 是为了和String类的hashCode()方法保持一致。
  4. hashCode()返回hashCode

images 提示您可能会发现使用HashCodeBuilder类比使用这种或另一种算法来创建哈希代码更容易(参见[commons.apache.org/lang/api-2.4/org/apache/commons/lang/builder/HashCodeBuilder.html](http://commons.apache.org/lang/api-2.4/org/apache/commons/lang/builder/HashCodeBuilder.html)了解该类的解释)。这个类遵循 Bloch 的规则,是 Apache Commons Lang 组件的一部分,可以从[commons.apache.org/lang/](http://commons.apache.org/lang/)下载。

在第二章,清单 2-27 的Point类覆盖equals()但不覆盖hashCode()。我后来展示了一小段代码,它必须附加到Pointmain()方法中,以演示不覆盖hashCode()的问题。我在这里重申这个问题:

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

清单 5-20 通过声明一个hashCode()方法来修改清单 2-27 的Point类。这个方法使用前面提到的算法来确保逻辑上等价的Point对象散列到同一个条目。

清单 5-20。使用散列表计算命令行参数

`import java.util.HashMap;
import java.util.Map;

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;
   }
   @Override
   public int hashCode()
   {
      int hashCode = 19;
      int hc = x;
      hashCode = hashCode31+hc;
      hc = y;
      hashCode = hashCode
31+hc;
      return hc;
   }
   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
      Map<Point, String> map = new HashMap<Point, String>();
      map.put(p1, "first point");
      System.out.println(map.get(p1)); // Output: first point
      System.out.println(map.get(new Point(10, 20))); // Output: null
   }
}`

清单 5-20 的hashCode()方法有点冗长,因为它将xy分别赋给局部变量hc,而不是在哈希代码计算中直接使用这些字段。然而,我决定采用这种方法来更好地反映哈希代码算法。

当您运行这个应用时,它的最后两行输出是您最感兴趣的。应用现在正确地在这两行上显示了first pointfirst point,而不是在两行上显示first pointnull

images 注意 LinkedHashMapHashMap的子类,它使用链表来存储条目。因此,LinkedHashMap的迭代器按照条目被插入的顺序返回条目。例如,如果清单 5-19 中的指定了Map<String, Integer> argMap = new LinkedHashMap<>();,那么java HashMapDemo how much wood could a woodchuck chuck if a woodchuck could chuck wood的应用输出将会是{how=1, much=1, wood=2, could=2, a=2, woodchuck=2, chuck=2, if=1}后跟Number of distinct arguments = 8

身份识别哈希图

IdentityHashMap类提供了一个Map实现,它在比较键和值时使用引用相等(==)而不是对象相等(equals())。这是对Map总合同的故意违反,总合同要求在比较元素时使用equals()

IdentityHashMap通过Systemstatic int identityHashCode(Object x)方法获取哈希码,而不是通过每个 key 的hashCode()方法。identityHashCode()x返回与ObjecthashCode()方法返回相同的哈希代码,不管x的类是否覆盖hashCode()。空引用的哈希代码为零。

这些特征使IdentityHashMap比其他Map实现具有性能优势。另外,IdentityHashMap支持可变键(用作键的对象,当它们的字段值在映射中改变时,它们的散列码也会改变)。清单 5-21 对比了IdentityHashMapHashMap中的可变键。

清单 5-21。在可变键上下文中对比IdentityHashMapHashMap

`import java.util.IdentityHashMap;
import java.util.HashMap;
import java.util.Map;

class IdentityHashMapDemo
{
   public static void main(String[] args)
   {
      Map<Employee, String> map1 = new IdentityHashMap<>();
      Map<Employee, String> map2 = new HashMap<>();
      Employee e1 = new Employee("John Doe", 28);
      map1.put(e1, "SALES");
      System.out.println(map1);
      Employee e2 = new Employee("Jane Doe", 26);
      map2.put(e2, "MGMT");
      System.out.println(map2);
      System.out.println("map1 contains key e1 = "+map1.containsKey(e1));
      System.out.println("map2 contains key e2 = "+map2.containsKey(e2));       e1.setAge(29);
      e2.setAge(27);
      System.out.println(map1);
      System.out.println(map2);
      System.out.println("map1 contains key e1 = "+map1.containsKey(e1));
      System.out.println("map2 contains key e2 = "+map2.containsKey(e2));
   }
}
class Employee
{
   private String name;
   private int age;
   Employee(String name, int age)
   {
      this.name = name;
      this.age = age;
   }
   @Override
   public boolean equals(Object o)
   {
      if (!(o instanceof Employee))
         return false;
      Employee e = (Employee) o;
      return e.name.equals(name) && e.age == age;
   }
   @Override
   public int hashCode()
   {
      int hashCode = 19;
      hashCode = hashCode31+name.hashCode();
      hashCode = hashCode
31+age;
      return hashCode;
   }
   void setAge(int age)
   {
      this.age = age;
   }
   void setName(String name)
   {
      this.name = name;
   }
   @Override
   public String toString()
   {
      return name+" "+age;
   }
}`

清单 5-21 的main()方法创建了IdentityHashMapHashMap实例,每个实例存储一个由Employee键和String值组成的条目。因为Employee实例是可变的(因为setAge()setName() ), main()改变它们的年龄,而这些键存储在它们的映射中。这些更改会产生以下输出:

{John Doe 28=SALES}
{Jane Doe 26=MGMT}
map1 contains key e1 = true
map2 contains key e2 = true
{John Doe 29=SALES}
{Jane Doe 27=MGMT}
map1 contains key e1 = true
map2 contains key e2 = false

最后四行显示更改的条目保留在它们的映射中。但是,map2containsKey()方法报告其HashMap实例不再包含其Employee键(应该是Jane Doe 27),而map1containsKey()方法报告其IdentityHashMap实例仍然包含其Employee键,现在是John Doe 29

images 注意 IdentityHashMap的文档指出“这个类的一个典型用途是保持拓扑的对象图转换,比如序列化或深度复制。”(我在第八章讨论序列化。)它还声明如下:“该类的另一个典型用途是维护代理对象。”同样,回应 stackoverflow 的“Identity HashMap 的用例”主题([stackoverflow.com/questions/838528/use-cases-for-identity-hashmap](http://stackoverflow.com/questions/838528/use-cases-for-identity-hashmap))的开发人员提到,当键是Class对象时,使用IdentityHashMapHashMap快得多。

WeakHashMap

WeakHashMap类提供了基于弱可达键的Map实现。因为每个键对象都是作为弱引用的被引用对象间接存储的,所以只有在垃圾收集器清除了对该键的所有弱引用(映射内部和外部)之后,该键才会自动从映射中删除。

images 注意查看第四章的“引用 API”部分,了解弱可达和弱引用。

相反,值对象是通过强引用存储的(不应该直接或间接地强引用它们自己的键,因为这样做可以防止它们的关联键被丢弃)。当从映射中删除一个键时,其关联的值对象也被删除。

清单 5-22 提供了一个WeakHashMap类的简单演示。

清单 5-22。检测弱散列表条目的移除

`import java.util.Map;
import java.util.WeakHashMap;

class LargeObject {
   private byte[] memory = new byte[1024102450]; // 50 megabytes
}
class WeakHashMapDemo
{
   public static void main(String[] args)
   {
      Map<LargeObject, String> map = new WeakHashMap<>();
      LargeObject lo = new LargeObject();
      map.put(lo, "Large Object");
      System.out.println(map);
      lo = null;
      while (!map.isEmpty())
      {
         System.gc();
         new LargeObject();
      }
      System.out.println(map);
   }
}`

清单 5-22 的main()方法在弱哈希表中存储一个 50MB 的LargeObject键和一个String值,然后通过将null赋值给lo来移除键的强引用。main() next 进入 while 循环,该循环一直执行到 map 为空为止(map.isEmpty()返回 true)。

每个循环迭代从一个System.gc()方法调用开始,这可能会也可能不会导致垃圾收集的发生(取决于平台)。为了鼓励垃圾收集,迭代会创建一个LargeObject对象并丢弃它的引用。这个活动最终会导致垃圾收集器运行并删除 map 的唯一条目。

当我在我的 Windows XP 平台上运行这个应用时,我观察到以下输出—如果您发现该应用处于无限循环中,您可能需要修改代码:

{LargeObject@5224ee=Large Object}
{}

images WeakHashMap对于避免内存泄漏很有用,Brian Goetz 的文章《Java 理论与实践:用弱引用堵塞内存泄漏》([www.ibm.com/developerworks/java/library/j-jtp11225/](http://www.ibm.com/developerworks/java/library/j-jtp11225/))中有解释。

编号

EnumMap类提供了一个Map实现,它的键是同一个枚举的成员。不允许空键;任何存储空键的尝试都会导致抛出NullPointerException。因为枚举映射在内部表示为数组,所以枚举映射在性能方面接近数组。

EnumMap提供以下构造函数:

  • EnumMap(Class<K> keyType)用指定的keyType创建一个空的枚举映射。当keyType包含空引用时,这个构造函数抛出NullPointerException
  • EnumMap(EnumMap<K,? extends V> m)使用与m相同的关键字类型和m的条目创建一个枚举映射。当m包含空引用时,这个构造函数抛出NullPointerException
  • EnumMap(Map<K,? extends V> m)创建一个用m的条目初始化的枚举映射。如果m是一个EnumMap实例,这个构造函数的行为就像前面的构造函数一样。否则,m必须包含至少一个条目,以便确定新枚举映射的键类型。当m包含空引用时,这个构造函数抛出NullPointerException,当m不是EnumMap实例并且为空时,抛出IllegalArgumentException

清单 5-23 演示了EnumMap

清单 5-23。常量Coin的枚举图

import java.util.EnumMap;
import java.util.Map;

enum Coin
{
   PENNY, NICKEL, DIME, QUARTER
}
class EnumMapDemo
{
   public static void main(String[] args)
   {
      Map<Coin, Integer> map = new EnumMap<>(Coin.class);
      map.put(Coin.PENNY, 1);
      map.put(Coin.NICKEL, 5);
      map.put(Coin.DIME, 10);
      map.put(Coin.QUARTER, 25);
      System.out.println(map);
      Map<Coin,Integer> mapCopy = new EnumMap<>(map);
      System.out.println(mapCopy);
   }
}

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

{PENNY=1, NICKEL=5, DIME=10, QUARTER=25}
{PENNY=1, NICKEL=5, DIME=10, QUARTER=25}

分类地图

TreeMap是一个排序映射的例子,这是一个按升序维护条目的映射,根据键的自然顺序或创建排序映射时提供的比较器进行排序。排序后的地图由SortedMap接口描述。

SortedMap,其泛型为SortedMap<K,V>,扩展了Map。除了两个例外,它从Map继承的方法在排序地图上的行为与在其他地图上的行为相同:

  • 由任何已排序地图的Collection视图上的iterator()方法返回的Iterator实例按顺序遍历集合。
  • Collection视图的toArray()方法返回的数组按顺序包含键、值或条目。

images 注意虽然没有保证,但是集合框架中SortedSet实现的Collection视图的toString()方法(比如TreeMap)会返回一个包含所有视图元素的字符串。

SortedMap的文档要求一个实现必须提供我在讨论TreeMap时提出的四个标准构造函数。此外,该接口的实现必须实现表 5-10 中描述的方法。

image

image

清单 5-24 展示了一个基于树形图的排序图。

清单 5-24。办公用品名称及数量分类图

import java.util.Comparator; import java.util.SortedMap; import java.util.TreeMap; class SortedMapDemo {    public static void main(String[] args)    {       SortedMap<String, Integer> smsi = new TreeMap<>();       String[] officeSupplies =       {          "pen", "pencil", "legal pad", "CD", "paper"       };       int[] quantities =       {          20, 30, 5, 10, 20       };       for (int i = 0; i < officeSupplies.length; i++)           smsi.put(officeSupplies[i], quantities[i]);       System.out.println(smsi);       System.out.println(smsi.headMap("pencil"));       System.out.println(smsi.headMap("paper"));       SortedMap<String, Integer> smsiCopy;       Comparator<String> cmp;       cmp = new Comparator<String>()                 {                    public int compare(String key1, String key2)                    {                       return key2.compareTo(key1); // descending order                    }                 };       smsiCopy = new TreeMap<String, Integer>(cmp);       smsiCopy.putAll(smsi);       System.out.println(smsiCopy);    } }

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

{CD=10, legal pad=5, paper=20, pen=20, pencil=30}
{CD=10, legal pad=5, paper=20, pen=20}
{CD=10, legal pad=5}
{pencil=30, pen=20, paper=20, legal pad=5, CD=10}

航海图

TreeMap是可导航地图的一个例子,这是一个排序的地图,可以按照降序和升序迭代,并且可以报告给定搜索目标的最接近匹配。导航地图由NavigableMap接口描述,其通用类型为NavigableMap<K,V>,扩展了SortedMap,在表 5-11 中有描述。

image

image

表 5-11 的方法描述了表 5-4 中呈现的NavigableSet方法的NavigableMap等价物,甚至在两个实例中返回NavigableSet实例。

清单 5-25 展示了一个基于树形地图的导航地图。

清单 5-25。导航地图(鸟类,在小面积内计数)条目

`import java.util.Iterator;
import java.util.NavigableMap;
import java.util.NavigableSet;
import java.util.TreeMap;

class NavigableMapDemo {
   public static void main(String[] args)
   {
      NavigableMap<String,Integer> nm = new TreeMap<>();
      String[] birds = { "sparrow", "bluejay", "robin" };
      int[] ints = { 83, 12, 19 };
      for (int i = 0; i < birds.length; i++)
         nm.put(birds[i], ints[i]);
      System.out.println("Map = "+nm);
      System.out.print("Ascending order of keys: ");
      NavigableSet ns = nm.navigableKeySet();
      Iterator iter = ns.iterator();
      while (iter.hasNext())
         System.out.print(iter.next()+" ");
      System.out.println();
      System.out.print("Descending order of keys: ");
      ns = nm.descendingKeySet();
      iter = ns.iterator();
      while (iter.hasNext())
         System.out.print(iter.next()+" ");
      System.out.println();
      System.out.println("First entry = "+nm.firstEntry());
      System.out.println("Last entry = "+nm.lastEntry());
      System.out.println("Entry < ostrich is "+nm.lowerEntry("ostrich"));
      System.out.println("Entry > crow is "+nm.higherEntry("crow"));
      System.out.println("Poll first entry: "+nm.pollFirstEntry());
      System.out.println("Map = "+nm);
      System.out.println("Poll last entry: "+nm.pollLastEntry());
      System.out.println("Map = "+nm);
   }
}`

清单 5-25 的System.out.println("Map = "+nm);方法调用依赖于TreeMaptoString()方法来获得可导航地图的内容。

运行该应用时,您会看到以下输出:

Map = {bluejay=12, robin=19, sparrow=83}
Ascending order of keys: bluejay robin sparrow
Descending order of keys: sparrow robin bluejay
First entry = bluejay=12
Last entry = sparrow=83
Entry < ostrich is bluejay=12
Entry > crow is robin=19
Poll first entry: bluejay=12
Map = {robin=19, sparrow=83}
Poll last entry: sparrow=83
Map = {robin=19}

工具

没有它的ArraysCollections工具类,集合框架是不完整的。每个类都提供各种类方法,在数组和集合的上下文中实现有用的算法。

以下是Arrays类的面向数组的实用方法的示例:

  • static <T> List<T> asList(T... a)返回一个由数组a支持的固定大小的列表。(对返回列表的更改“直写”到数组。)例如,List<String> birds = Arrays.asList("Robin", "Oriole", "Bluejay");String的三元素数组(回想一下,变量参数序列被实现为数组)转换为List,其引用被分配给birds
  • static int binarySearch(int[] a, int key)使用二分搜索法算法在数组a中搜索条目key(在下面的列表中解释)。调用此方法之前,必须对数组进行排序;否则,结果是不确定的。此方法返回搜索关键字的索引(如果它包含在数组中);否则,返回(-(插入点)-1)。插入点是key将被插入到数组中的点(第一个元素的索引大于key,或者如果数组中的所有元素都小于key,则为a.length,并且当且仅当找到key时,保证返回值将大于或等于 0。例如,Arrays.binarySearch(new String[] {"Robin", "Oriole", "Bluejay"}, "Oriole")返回 1,"Oriole"的索引。
  • static void fill(char[] a, char ch)ch存储在指定字符数组的每个元素中。例如,Arrays.fill(screen[i], ' ');用空格填充 2D screen数组的第i行。
  • static void sort(long[] a)将长整数数组a中的元素按数字升序排序;例如,long lArray = new long[] { 20000L, 89L, 66L, 33L}; Arrays.sort(lArray);
  • 使用比较器c对数组a中的元素进行排序。例如,当给定Comparator<String> cmp = new Comparator<String>() { public int compare(String e1, String e2) { return e2.compareTo(e1); } }; String[] innerPlanets = { "Mercury", "Venus", "Earth", "Mars" };Arrays.sort(innerPlanets, cmp);使用cmp来帮助将innerPlanets排序为其元素的降序:VenusMercuryMarsEarth是结果。

在数组中搜索特定元素有两种常见的算法。线性搜索从索引 0 到被搜索元素的索引或数组的结尾,逐个元素地搜索数组。平均来说,必须搜索一半的元素;更大的数组需要更长的搜索时间。但是,数组不需要排序。

相比之下,二分搜索法在有序数组 an 项中搜索元素 e 的时间要快得多。它通过递归执行以下步骤来工作:

  1. 将低索引设置为 0。
  2. 将高索引设置为 n-1。
  3. 如果低索引>高索引,则打印“无法找到”e. End。
  4. 将中间指数设置为(低指数+高指数)/2。
  5. 如果 e > a[中间索引],则将低索引设置为中间索引+1。转到第三部分。
  6. 如果 e < a[中间索引],则将高索引设置为中间索引-1。转到第三部分。
  7. 打印“找到”e“在索引”中间索引。

该算法类似于在电话簿中最优地查找名字。从打开书到正中间开始。如果名字不在那一页上,就把书翻到前半部分或后半部分的正中间,这取决于名字出现在哪一半。重复,直到找到名称(或找不到)。

对 4,000,000,000 个元素应用线性搜索会导致大约 2,000,000,000 次比较(平均),这需要时间。相比之下,对 4,000,000,000 个元素应用二分搜索法最多会产生 32 个比较。这就是为什么Arrays包含binarySearch()方法而不是linearSearch()方法。

以下是Collections类的面向集合的类方法的示例:

  • static <T extends Object&Comparable<? super T>> T min(Collection<? extends T> c)根据元素的自然排序返回集合c的最小元素。例如,System.out.println(Collections.min(Arrays.asList(10, 3, 18, 25)));输出3。所有c的元素都必须实现Comparable接口。此外,所有要素必须相互可比。当c为空时,该方法抛出NoSuchElementException
  • static void reverse(List<?> l)反转列表l元素的顺序。例如,List<String> birds = Arrays.asList("Robin", "Oriole", "Bluejay"); Collections.reverse(birds); System.out.println(birds);导致[Bluejay, Oriole, Robin]作为输出。
  • static <T> List<T> singletonList(T o)返回一个只包含对象o的不可变列表。例如,list.removeAll(Collections.singletonList(null));list中删除所有的null元素。
  • static <T> Set<T> synchronizedSet(Set<T> s)返回由集合s支持的同步(线程安全)集合;例如,Set<String> ss = Collections.synchronizedSet(new HashSet<String>());。为了保证串行访问,对后备集的所有访问都必须通过返回集来完成,这一点至关重要。
  • static <K,V> Map<K,V> unmodifiableMap(Map<? extends K,? extends V> m)返回地图m的不可修改视图;例如,Map<String, Integer> msi = Collections.synchronizedMap(new HashMap<String, Integer>());。对返回的地图的查询操作“通读”指定的地图,并试图修改返回的地图,无论是直接还是通过它的集合视图,都会导致一个UnsupportedOperationException

images 注意由于性能原因,集合实现是不同步的——不同步的集合比同步的集合有更好的性能。然而,要在多线程环境中使用集合,您需要获得该集合的同步版本。您可以通过调用诸如synchronizedSet()这样的方法来获得该版本。

您可能想知道Collections类中各种“empty”类方法的用途。例如,static final <T> List<T> emptyList()返回一个不可变的空列表,如List<String> ls = Collections.emptyList();所示。这些方法之所以存在,是因为它们提供了一种在特定上下文中返回 null(并避免潜在的 ??)的有用替代方法。考虑清单 5-26 。

清单 5-26。空的和非空的Bird s 中的Lists

import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;

class Birds
{
   private List<String> birds;
   Birds()
   {
      birds = Collections.emptyList();
   }
   Birds(String... birdNames)
   {
      birds = new ArrayList<String>();
      for (String birdName: birdNames)
         birds.add(birdName);
   }
   @Override
   public String toString()
   {
      return birds.toString();
   }
}

class EmptyListDemo
{
   public static void main(String[] args)
   {
      Birds birds = new Birds();
      System.out.println(birds);
      birds = new Birds("Swallow", "Robin", "Bluejay", "Oriole");
      System.out.println(birds);
   }
}

清单 5-26 声明了一个Birds类,它在一个列表中存储了各种鸟类的名字。这个类提供了两个构造函数,一个是无参数构造函数,另一个是采用可变数量的String参数来标识不同的鸟的构造函数。

noargument 构造函数调用emptyList()将它的私有birds字段初始化为空的StringemptyList()List是一个泛型方法,编译器从它的上下文推断它的返回类型。

如果你想知道emptyList()的必要性,看看toString()方法。注意,这个方法计算的是birds.toString()。如果我们没有将一个对空List<String>的引用分配给birdsbirds将包含null(创建对象时该实例字段的默认值),并且当试图评估birds.toString()时将抛出一个NullPointerException实例。

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

[]
[Swallow, Robin, Bluejay, Oriole]

emptyList()方法实现如下:return (List<T>) EMPTY_LIST;。该语句返回分配给Collections类中的EMPTY_LIST类字段的单个List实例。

你可能想直接使用EMPTY_LIST,但是如果你这样做,你会遇到一个未检查的警告消息,因为EMPTY_LIST被声明为原始类型List,混合原始类型和泛型类型会导致这样的消息。虽然您可以取消警告,但是最好使用emptyList()方法。

假设您向Birds添加了一个void setBirds(List<String> birds)方法,并向该方法传递一个空列表,如在birds.setBirds(Collections.emptyList());中所示。编译器将响应一条错误消息,指出它要求参数的类型为List<String>,但是参数的类型为List<Object>。它这样做是因为编译器无法从上下文中找出正确的类型,所以它选择了List<Object>

有一个方法可以解决这个问题,这个方法看起来可能会很奇怪。指定birds.setBirds(Collections.<String>emptyList());,其中形式类型参数列表及其实际类型参数出现在成员访问操作符之后、方法名之前。编译器现在知道正确的类型参数是String,并且emptyList()将返回List<String>

传统集合 API

Java 1.2 引入了集合框架。在 Java 包含框架之前,开发人员有两种选择:创建他们自己的框架,或者使用 Java 1.0 引入的VectorEnumerationStackDictionaryHashtablePropertiesBitSet类型。

Vector是一个描述可增长数组的具体类,很像ArrayList。与ArrayList实例不同的是,Vector实例是同步的。Vector已经被通用化并被改进以支持集合框架,这使得像List<String> list = new Vector<String>();这样的语句变得合法。

集合框架提供了Iterator来迭代集合的元素。相反,Vectorelements()方法通过EnumerationhasMoreElements()nextElement()方法返回实现枚举(迭代并返回)Vector实例元素的Enumeration接口的类的实例。

Vector由具体的Stack类子类化,它代表一个 LIFO 数据结构。Stack提供了一个E push(E item)方法,用于将一个对象推到堆栈上,一个E pop()方法,用于从堆栈顶部弹出一个项目,以及其他一些方法,比如用于确定堆栈是否为空的boolean empty()

Stack是糟糕的 API 设计的一个很好的例子。通过从Vector继承,可以调用Vectorvoid add(int index, E element)方法在任意位置添加元素,并破坏Stack 实例的完整性。事后看来,Stack应该在设计中使用组合:使用一个Vector实例来存储一个Stack实例的元素。

Dictionary是将键映射到值的子类的抽象超类。具体的Hashtable类是Dictionary的唯一子类。与Vector一样,HashTable实例被同步,HashTable被通用化,HashTable被改进以支持集合框架。

HashtableProperties子类化,这是一个具体的类,表示一组持久的属性(标识应用设置的基于String的键/值对)。Properties提供了用于存储属性的Object setProperty(String key, String value),以及用于返回属性值的public String getProperty(String key)

images 注意应用的各种用途的使用属性。例如,如果您的应用有一个图形用户界面,您可以通过一个Properties对象将其主窗口的屏幕位置和大小保存到一个文件中,这样应用就可以在下次运行时恢复窗口的位置和大小。

Properties是糟糕的 API 设计的另一个好例子。通过从Hashtable继承,您可以调用HashtableV put(K key, V value)方法来存储一个带有非String键和/或非String值的条目。事后看来,Properties应该利用组合:将一个Properties实例的元素存储在一个Hashtable实例中。

images 第二章讨论包装类,这就是StackProperties应该如何实现的。

最后,BitSet是一个具体的类,它描述了一组可变长度的位。这个类表示任意长度的位集的能力与前面描述的基于整数的固定长度位集形成对比,固定长度位集有最大成员数限制:基于int的位集有 32 个成员,基于long的位集有 64 个成员。

BitSet提供了一对用于初始化BitSet实例的构造函数:BitSet()初始化实例以初始存储依赖于实现的位数,而BitSet(int nbits)初始化实例以初始存储nbits位。BitSet还提供了多种方法,包括以下几种:

  • void and(BitSet bs)bs对该位集进行位与运算。修改该位集,使得当它和bs中相同位置的位为 1 时,该位被设置为 1。
  • void andNot(BitSet bs)将该位集中的所有位设置为 0,其对应的位在bs中设置为 1。
  • void clear()将该位集中的所有位设置为 0。
  • Object clone()克隆该位集以产生新的位集。克隆的位设置为 1,与此位集完全相同。
  • boolean get(int bitIndex)返回该位集的位的值,作为从零开始的bitIndex的布尔真/假值(真为 1,假为 0)。当bitIndex小于 0 时,该方法抛出IndexOutOfBoundsException
  • int length()返回该位集的“逻辑大小”,即最高 1 位加 1 的索引,或者当该位集不包含 1 位时返回 0。
  • void or(BitSet bs)bs对该位集进行位异或运算。修改该位集,使得当该位或bs中相同位置的位为 1 时,或者当两个位都为 1 时,该位被设置为 1。
  • void set(int bitIndex, boolean value)将从零开始的bitIndex位设置为value(真转换为 1;false 转换为 0)。当bitIndex小于 0 时,该方法抛出IndexOutOfBoundsException
  • int size()返回该位集用来表示位值的位数。
  • String toString()根据为 1 的位的位置返回该位集的字符串表示形式;比如{4, 5, 9, 10}
  • void xor(BitSet set)bs对该位集进行按位异或运算。修改该位集,使得当该位或bs中相同位置的位(但不是两者)为 1 时,该位被设置为 1。

清单 5-27 展示了一个应用,它演示了其中的一些方法,并让你更深入地了解按位 AND ( &)、按位异或(|)和按位异或(^)运算符是如何工作的。

清单 5-27。使用可变长度位集

`import java.util.BitSet;

class BitSetDemo
{
   public static void main(String[] args)
   {
      BitSet bs1 = new BitSet();
      bs1.set(4, true);
      bs1.set(5, true);
      bs1.set(9, true);
      bs1.set(10, true);
      BitSet bsTemp = (BitSet) bs1.clone();
      dumpBitset("        ", bs1);
      BitSet bs2 = new BitSet();
      bs2.set(4, true);
      bs2.set(6, true);
      bs2.set(7, true);
      bs2.set(9, true);
      dumpBitset("        ", bs2);
      bs1.and(bs2);
      dumpSeparator(Math.min(bs1.size(), 16));
      dumpBitset("AND (&) ", bs1);       System.out.println();
      bs1 = bsTemp;
      dumpBitset("        ", bs1);
      dumpBitset("        ", bs2);
      bsTemp = (BitSet) bs1.clone();
      bs1.or(bs2);
      dumpSeparator(Math.min(bs1.size(), 16));
      dumpBitset("OR (|)  ", bs1);
      System.out.println();
      bs1 = bsTemp;
      dumpBitset("        ", bs1);
      dumpBitset("        ", bs2);
      bsTemp = (BitSet) bs1.clone();
      bs1.xor(bs2);
      dumpSeparator(Math.min(bs1.size(), 16));
      dumpBitset("XOR (^) ", bs1);
   }
   static void dumpBitset(String preamble, BitSet bs)
   {
      System.out.print(preamble);
      int size = Math.min(bs.size(), 16);
      for (int i = 0; i < size; i++)
         System.out.print(bs.get(i) ? "1" : "0");
      System.out.print("  size("+bs.size()+"), length("+bs.length()+")");
      System.out.println();
   }
   static void dumpSeparator(int len)
   {
      System.out.print("        ");
      for (int i = 0; i < len; i++)
         System.out.print("-");
      System.out.println();
   }
}`

为什么我在dumpBitset()中指定了Math.min(bs.size(), 16),并且给dumpSeparator()传递了一个类似的表达式?我想精确地显示 16 位和 16 个破折号(为了美观),并且需要考虑位集的大小小于 16。虽然这不会发生在 JDK 的BitSet级上,但它可能会发生在非 JDK 的变体上。

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

`0000110001100000  size(64), length(11)
        0000101101000000  size(64), length(10)
        ----------------
AND (&) 0000100001000000  size(64), length(10)

0000110001100000  size(64), length(11)
        0000101101000000  size(64), length(10)
        ----------------
OR (|)  0000111101100000  size(64), length(11)

0000110001100000  size(64), length(11)         0000101101000000  size(64), length(10)
        ----------------
XOR (^) 0000011100100000  size(64), length(11)`

images 小心VectorHashtable不同,BitSet不是同步的。当在多线程环境中使用BitSet时,您必须在外部同步对此类的访问。

集合框架已经使VectorStackDictionaryHashtable过时了。这些类型仍然是标准类库的一部分,以支持遗留代码。

框架的Iterator接口已经在很大程度上淘汰了Enumeration接口。但是,因为java.util.StringTokenizer类(这个类有些用处,在第六章中有简要讨论)使用了Enumeration,这个接口还是有一定可信度的。

Preferences API(参见附录 C)已经让Properties大部分过时了。然而,标准类库仍然在不同的地方使用Properties(比如在 XSLT 的上下文中,在第十章中讨论过)。这个类可能也有一些用途。

因为BitSet仍然相关,所以这个类继续改进。比如 Java 7 在这个类中引入了新的valueOf()类方法(比如static BitSet valueOf(byte[] bytes))和实例方法(比如int previousSetBit(int fromIndex))。

images 注意当您意识到可变长度位集的有用性时,BitSet仍在被改进就不足为奇了(直到撰写本文时的 Java 7)。由于其紧凑性和其他优点,变长位集通常用于实现操作系统的优先级队列,并有助于内存页面分配。面向 Unix 的文件系统也使用位集来帮助分配索引节点(信息节点)和磁盘扇区。比特集在霍夫曼编码中很有用,霍夫曼编码是一种实现无损数据压缩的数据压缩算法。

打造自己的收藏

数组、集合框架和诸如BitSet之类的遗留类适合于组织对象组(或者,在BitSet的情况下,被解释为布尔值 true/false 的位集合),在创建自己的集合 API 之前,您应该尽可能地使用它们。毕竟,为什么要“重新发明轮子?”

集合框架支持列表、集合、队列、队列和映射。如果您的收集需求符合这些类别之一,那么就使用这个框架。请记住,您还可以利用TreeSetTreeMap实现上下文中的树,以及 deque 上下文中的堆栈。

也许您需要集合框架核心接口的不同实现。如果是这样,您可以通过实现接口来扩展这个框架,或者通过子类化一个更方便的“Abstract”类,比如AbstractQueue。例如,作者凯·霍斯特曼演示了如何扩展这个类来实现循环数组队列(参见 [www.java2s.com/Code/Java/Collections-Data-Structure/Howtoextendthecollectionsframework.htm](http://www.java2s.com/Code/Java/Collections-Data-Structure/Howtoextendthecollectionsframework.htm))。

遵守合同

当您实现一个核心接口或扩展一个Abstract类时,您应该确保您的实现类不会偏离 Java 文档中为这些接口描述的各种契约。例如,List对它从Collection继承的hashCode()方法做出如下规定:

列表的哈希码被定义为以下计算的结果:

int hashCode = 1;
for (E e: list)
   hashCode = 31*hashCode+(e==null ? 0 : e.hashCode());

该计算确保list1.equals(list2)对于Object.hashCode()总包要求的list1list2两个清单中的任意一个来说都意味着list1.hashCode() == list2.hashCode()

部分实现了ListAbstractList类对hashCode() : 做了如下描述:这个实现使用了在List.hashCode()方法的文档中用来定义列表散列函数的代码。

说到列表,您还应该知道RandomAccess接口:

ArrayList实现了RandomAccess接口,这是一个标记接口,由List实现类使用,以表明它们支持快速(通常是常数时间)随机访问。该接口的主要目的是允许通用算法改变它们的行为,以便在应用于随机或顺序访问列表时提供良好的性能。

操纵随机访问List实现(如ArrayList)的最佳算法,在应用于顺序访问List实现(如LinkedList)时,可以产生二次行为。鼓励通用列表算法在应用一个如果应用于顺序访问列表会提供较差性能的算法之前,检查给定列表是否是该接口的实例(通过instanceof),并在必要时改变它们的行为以保证可接受的性能。

随机访问和顺序访问之间的区别通常很模糊。例如,一些List实现提供了渐近线性(到给定曲线的距离趋向于零)的访问时间,如果它们在实践中获得了巨大但恒定的访问时间。这样的List实现类一般应该实现这个接口。作为一个经验法则,List实现类应该实现这个接口,如果,对于类的典型实例,下面的循环:

for (int i=0, n=list.size(); i < n; i++) list.get(i);

运行速度比以下循环快:

for (Iterator i=list.iterator(); i.hasNext();) i.next();

记住这些建议,你会发现扩展集合框架更容易。

您可能需要一个不受集合框架支持的集合(或者您可能只是认为它不受支持)。例如,你可能想要建立一个稀疏矩阵,一个许多或大部分元素为零的表格(见[en.wikipedia.org/wiki/Sparse_matrix](http://en.wikipedia.org/wiki/Sparse_matrix))。例如,稀疏矩阵是实现电子表格的良好数据结构。

如果元素代表位,你可以用BitSet来代表矩阵。如果元素是对象,可以使用数组。这两种方法的问题都在于可伸缩性和堆空间的限制。例如,假设您需要一个有 100,000 行和 100,000 列的表,最多产生 100 亿个元素。

你可以忘记使用BitSet(假设每个条目占用一位)因为 10,000,000,000 对于传递给BitSet(int nbits)构造函数来说太大了;将这个长整型强制转换为整型时,会丢失一些信息。您也可以忘记使用数组,因为您将耗尽 JVM 的内存并在运行时获得一个java.lang.OutOfMemoryError

因为您处理的是一个稀疏矩阵,所以假设任何时候非零的表条目都不超过 25,000 个。毕竟,稀疏矩阵的非零元素数量很少。这更容易管理。

您不会使用BitSet来表示这个矩阵,因为您将假设每个矩阵条目都是一个对象。您不能使用二维数组来存储这些对象,因为该数组需要 100,000 行乘以 100,000 列来正确地索引稀疏矩阵,并且您会因为存储零值(或者 null,在 object 的情况下)而耗尽内存。

还有另一种方法来表示这个矩阵,那就是创建一个节点链表。

一个节点是一个由值和链接字段组成的对象。与数组不同,在数组中,每个元素存储同一基元类型或引用超类型的单个值,而节点可以存储不同类型的多个值。它还可以存储链接(对其他节点的引用)。

考虑清单 5-28 中的Node类:

清单 5-28。节点由值字段和链接字段组成

class Node
{
   // value field

   String name;
   // link field

   Node next;
}

Node描述简单节点,其中每个节点由一个name值字段和一个next链接字段组成。注意next和声明它的类是同一个类型。这种安排让一个节点实例在这个字段中存储对另一个节点实例(即下一个节点)的引用。产生的节点被链接在一起。

清单 5-29 展示了一个Nodes类,它演示了将Node连接到一个链表,然后遍历这个链表来输出name字段的值。

清单 5-29。创建并迭代节点链表

class Nodes {    public static void main(String[] args)    {       Node top = new Node();       top.name = "node 1";       top.next = new Node();       top.next.name = "node 2";       top.next.next = new Node();       top.next.next.name = "node 3";       top.next.next.next = null;       Node temp = top;       while (temp != null)       {          System.out.println(temp.name);          temp = temp.next;       }    } }

清单 5-29 展示了一个单链表(一个每个节点由一个链接字段组成的列表)的创建。第一个Node实例由引用变量top指向,它标识列表的顶部。该链表中的每个后续节点都是从其前任的next字段中引用的。最后的next字段被设置为null以表示链表的结束。(这种显式初始化是不必要的,因为在实例初始化期间,该字段默认为空引用,但为了清楚起见,它仍然存在)。

图 5-5 揭示了这个三节点链表。

images

图 5-5。引用变量top指向这个三节点链表中的第一个节点。

列表 5-29 也向您展示了如何通过跟随每个Node对象的next字段来遍历这个单链表。遍历之前,将top的引用分配给变量temp,以保留该链表的开始,从而可以执行进一步的操作(节点插入、移除、更新)和搜索。

while 循环迭代,直到temp包含空引用,输出每个节点的name字段,并将当前节点的next字段中的引用赋给temp

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

node 1
node 2
node 3

您可以声明下面的Cell类来表示电子表格的稀疏矩阵节点,它被称为单元格:

class Cell
{
   int row;
   int col;
   Object value;
   Node next;
}

当被调用来更新屏幕上的电子表格时,您的电子表格应用的呈现代码遍历它的Cell节点链表。对于每个单元格,它首先检查(rowcol)以了解该单元格是否可见以及是否应该被呈现。如果单元格可见,instanceof操作符用于确定value的类型,然后显示value。一旦遇到null,呈现代码就知道没有更多的电子表格元素要呈现了。

在创建你自己的链表类来存储Cell实例之前,你应该意识到这样做是没有必要的。相反,您可以利用集合框架的LinkedList类来存储Cell实例(没有不必要的next字段)。尽管您可能偶尔需要创建自己的基于节点的集合,但这个练习的寓意是,在发明自己的 API 来收集对象之前,您应该始终考虑使用数组、集合框架或诸如BitSet之类的遗留类。

练习

以下练习旨在测试你对系列的理解:

  1. 作为数组列表有用性的一个例子,创建一个JavaQuiz应用,展示一个关于 Java 特性的基于多项选择的测验。JavaQuiz类的main()方法首先用QuizEntry数组中的条目填充数组列表(例如,new QuizEntry("what was java's original name?", new String[] { "Oak", "Duke", "J", "None of the above" },'a'))。每个条目由一个问题、四个可能的答案和正确答案的字母(A、B、C 或 D)组成。main()然后使用数组列表的iterator()方法返回一个Iterator实例,这个实例的hasNext()next()方法迭代列表。每次迭代输出问题和四个可能的答案,然后提示用户输入正确的选择。用户输入 A、B、C 或 D(通过System.in.read())后,main()输出一条消息,说明用户是否做出了正确的选择。
  2. 创建一个单词计数应用(WC),它从标准输入(通过System.in.read())中读取单词,并将它们和它们的频率计数一起存储在一个映射中。在这个练习中,一个单词只由字母组成;使用java.lang.Character类的isLetter()方法来做这个决定。此外,使用Mapget()put()方法,利用自动装箱来记录新条目或更新现有条目的计数——第一次看到一个单词时,它的计数被设为 1。使用MapentrySet()方法返回一个Set条目,并遍历这些条目,将每个条目输出到标准输出。
  3. Collections提供了static int frequency(Collection<?> c, Object o)方法来返回集合中与o相等的c元素的数量。创建一个FrequencyDemo应用,该应用读取其命令行参数,并将除最后一个参数之外的所有参数存储在一个列表中,然后调用frequency(),将列表和最后一个命令行参数作为该方法的参数。然后,它输出该方法的返回值(最后一个命令行参数在前面的命令行参数中出现的次数)。比如java FrequencyDemo要输出Number of occurrences of null = 0java FrequencyDemo how much wood could a woodchuck chuck if a woodchuck could chuck wood wood要输出Number of occurrences of wood = 2

总结

集合框架是表示和操作集合的标准架构,集合是存储在为此目的而设计的类实例中的对象组。这个框架主要由核心接口、实现类和工具类组成。

核心接口使得独立于集合的实现来操作集合成为可能。它们包括IterableCollectionListSetSortedSetNavigableSetQueueDequeMapSortedMapNavigableMapCollection延伸IterableListSetQueue各延伸CollectionSortedSet延伸SetNavigableSet延伸SortedSetDeque延伸QueueSortedMap延伸MapNavigableMap伸出SortedMap

框架的实现类包括ArrayListLinkedListTreeSetHashSetLinkedHashSetEnumSetPriorityQueueArrayDequeTreeMapHashMapLinkedHashMapIdentityHashMapWeakHashMapEnumMap。每个具体类的名称都以核心接口名称结尾,标识它所基于的核心接口。

框架的实现类还包括抽象的AbstractCollectionAbstractListAbstractSequentialListAbstractSetAbstractQueueAbstractMap类。这些类提供了核心接口的框架实现,以便于创建具体的实现类。

没有它的ArraysCollections工具类,集合框架是不完整的。每个类都提供各种类方法,在数组和集合的上下文中实现有用的算法。

在 Java 1.2 引入集合框架之前,开发人员在集合方面有两种选择:创建自己的框架,或者使用 Java 1.0 引入的VectorEnumerationStackDictionaryHashtablePropertiesBitSet类型。

集合框架已经使VectorStackDictionaryHashtable过时了。框架的Iterator接口已经在很大程度上淘汰了Enumeration接口。Preferences API 已经让Properties在很大程度上过时了。因为BitSet仍然相关,所以这个类继续改进。

数组、集合框架和诸如BitSet之类的遗留类适合于组织对象组(或者,在BitSet的情况下,被解释为布尔值 true/false 的位集合),在创建自己的集合 API 之前,您应该尽可能地使用它们。

然而,您可能需要集合框架核心接口之一的不同实现。如果是这样,您可以通过实现接口来扩展这个框架,或者通过子类化一个更方便的“Abstract”类,比如AbstractQueue

您可能需要一个不受集合框架支持的集合(或者您可能只是认为它不受支持)。例如,您可能想要建立一个稀疏矩阵的模型,这是一个许多或大多数元素为零的表。例如,稀疏矩阵是实现电子表格的良好数据结构。

要对电子表格或其他稀疏矩阵建模,您可以使用节点,这些节点是由值和链接字段组成的对象。与数组不同,在数组中,每个元素存储同一基元类型或引用超类型的单个值,而节点可以存储不同类型的多个值。它还可以存储对其他节点的引用,这就是所谓的链接。

您可以将节点连接到链表中,但是(至少对于单向链表来说)没有必要这样做,因为您可以利用 Collections 框架的LinkedList类来完成这项任务。毕竟,你不应该“重新发明轮子”

概括地说,集合框架是工具 API 的一个例子。第六章继续关注工具 API,向您介绍 Java 的并发工具,它扩展了集合框架、java.util.Objects类等等。

六、浏览附加工具 API

第五章向您介绍了集合框架,这是一个实用 API 的集合。第六章介绍了额外的工具 API,特别是并发工具ObjectsRandom

并发工具

Java 5 引入了并发工具,它们是简化并发(多线程)应用开发的类和接口。这些类型位于java.util.concurrent包及其java.util.concurrent.atomicjava.util.concurrent.locks子包中。

并发工具在其实现中利用低级线程 API(参见第四章)并提供高级构建模块来简化多线程应用的创建。它们被组织成执行器、同步器、并发收集、锁、原子变量和其他工具类别。

执行人

第四章介绍了线程 API,它让你可以通过像new Thread(new RunnableTask()).start();这样的表达式来执行可运行的任务。这些表达式将任务提交与任务的执行机制紧密耦合(在当前线程、新线程或从线程池[组]中任意选择的线程上运行)。

images 注意一个任务是一个对象,它的类实现了java.lang.Runnable接口(一个可运行的任务)或者java.util.concurrent.Callable接口(一个可调用的任务)。

并发工具提供执行器作为执行可运行任务的低级线程 API 表达式的高级替代。一个执行器是一个对象,它的类直接或间接地实现了java.util.concurrent.Executor接口,该接口将任务提交与任务执行机制相分离。

images 注意executor 框架使用接口将任务提交与任务执行机制解耦,类似于 Collections 框架使用核心接口将列表、集合、队列、队列和映射与它们的实现解耦。解耦产生了更易于维护的灵活代码。

Executor声明了一个单独的void execute(Runnable runnable)方法,该方法在未来的某个时间执行名为runnable的可运行任务。execute()runnablenull时抛出java.lang.NullPointerException,不能执行runnable时抛出java.util.concurrent.RejectedExecutionException

images RejectedExecutionException当一个执行程序正在关闭并且不想接受新任务的时候可以抛出。此外,当执行程序没有足够的空间来存储任务时,也会抛出这个异常(也许执行程序使用了一个有界的阻塞队列来存储任务,而队列已经满了——我将在本章的后面讨论阻塞队列)。

下面的例子给出了前面提到的new Thread(new RunnableTask()).start();表达式的Executor等价物:

Executor executor = ...; //  ... represents some executor creation
executor.execute(new RunnableTask());

虽然Executor很容易使用,但是这个接口在各方面都有限制:

  • Executor只关注Runnable。因为Runnablerun()方法不返回值,所以对于一个可运行的任务来说,没有方便的方法向它的调用者返回值。
  • Executor没有提供一种方法来跟踪执行可运行任务的进度,取消正在执行的可运行任务,或者确定可运行任务何时完成执行。
  • Executor无法执行可运行任务的集合。
  • 没有为应用提供关闭执行程序的方法(更不用说正确关闭执行程序了)。

这些限制由java.util.concurrent.ExecutorService接口解决,该接口扩展了Executor,其实现通常是一个线程池(一组可重用的线程)。表 6-1 描述了ExecutorService的方法。

image

image

表 6-1 是指java.util.concurrent.TimeUnit,一个以给定粒度单位表示持续时间的枚举:DAYSHOURSMICROSECONDSMILLISECONDSMINUTESNANOSECONDSSECONDS。此外,TimeUnit声明了用于跨单元转换的方法(例如,long toHours(long duration)),以及用于在这些单元中执行定时和延迟操作的方法(例如,void sleep(long timeout))。

表 6-1 也指可调用任务,类似于可运行任务。与Runnable不同,它的void run()方法不能抛出被检查的异常,Callable<V>声明了一个返回一个值的V call()方法,它可以抛出被检查的异常,因为call()是用一个throws Exception子句声明的。

最后,表 6-1 引用了Future接口,它代表了一个异步计算的结果。Future,其泛型类型为Future<V>,提供了取消任务、返回任务值以及确定任务是否完成的方法。表 6-2 描述了Future的方法。

image

image

假设您打算编写一个应用,它的图形用户界面(GUI)允许用户输入单词。用户输入单词后,应用将这个单词呈现给几个在线词典,并获得每个词典的条目。这些条目随后显示给用户。

因为在线访问可能很慢,而且用户界面应该保持响应(也许用户想要结束应用),所以您将“获取单词条目”任务卸载到一个在单独线程上运行该任务的执行器。下面的例子使用了ExecutorServiceCallableFuture来实现这个目标:

ExecutorService executor = ...; //  ... represents some executor creation
Future<String[]> taskFuture = executor.submit(new Callable<String[]>()
                                              {
                                                  public String[] call()
                                                  {
                                                     String[] entries = ...;
                                                     // Access online dictionaries
                                                     // with search word and populate
                                                     // entries with their resulting
                                                     // entries.
                                                     return entries;
                                                  }
                                              });
// Do stuff.
String entries = taskFuture.get();

在以某种方式获得一个执行程序后(您将很快了解如何获得),该示例的主线程向执行程序提交一个可调用的任务。submit()方法立即返回一个对用于控制任务执行和访问结果的Future对象的引用。主线程最终调用这个对象的get()方法来获得这些结果。

images 注意java.util.concurrent.ScheduledExecutorService接口扩展了ExecutorService并描述了一个执行器,让你安排任务运行一次或在给定延迟后定期执行。

虽然您可以创建自己的ExecutorExecutorServiceScheduledExecutorService实现(比如class DirectExecutor implements Executor { public void execute(Runnable r) { r.run(); } }—直接在调用线程上运行 executor),但是并发工具提供了一个更简单的选择:java.util.concurrent.Executors

images 提示如果您打算创建自己的ExecutorService实现,您会发现使用java.util.concurrent.AbstractExecutorServicejava.util.concurrent.FutureTask类会很有帮助。

Executors工具类声明了几个类方法,这些方法返回各种ExecutorServiceScheduledExecutorService实现的实例(以及其他类型的实例)。这个类的static方法完成以下任务:

  • 创建并返回一个用常用配置设置配置的ExecutorService实例。
  • 创建并返回一个用常用配置设置配置的ScheduledExecutorService实例。
  • 创建并返回一个“包装的”ExecutorServiceScheduledExecutorService实例,通过使特定于实现的方法不可访问来禁用执行器服务的重新配置。
  • 创建并返回一个用于创建新线程的java.util.concurrent.ThreadFactory实例。
  • 从其他类似闭包的形式中创建并返回一个Callable实例,这样它就可以用在需要Callable参数的执行方法中(例如,ExecutorServicesubmit(Callable)方法)。(查看维基百科的“闭包(计算机科学)”条目[ [en.wikipedia.org/wiki/Closure_(computer_science)](http://en.wikipedia.org/wiki/Closure_(computer_science)) ]来了解闭包。)

例如,static ExecutorService newFixedThreadPool(int nThreads)创建一个线程池,它重用固定数量的线程,这些线程在一个共享的无界队列上运行。最多nThreads线程主动处理任务。如果在所有线程都处于活动状态时提交了额外的任务,它们将在队列中等待一个可用的线程。

如果在执行器关闭之前,任何一个线程由于执行过程中的故障而终止,那么在需要执行后续任务时,一个新的线程将取代它的位置。在明确关闭执行器之前,线程池中的线程将一直存在。当您将零或负值传递给nThreads时,该方法抛出IllegalArgumentException

images 注意线程池用于消除为每个提交的任务创建一个新线程的开销。线程创建并不便宜,而且创建许多线程会严重影响应用的性能。

您通常会在输入/输出上下文中使用执行器、可运行的、可调用的和未来的。(我在第八章的中讨论了 Java 对文件系统输入/输出的支持。)执行冗长的计算提供了您可以使用这些类型的另一个场景。例如,清单 6-1 在欧拉数 e (2.71828…)的计算上下文中使用了一个执行程序、一个可调用程序和一个未来。

清单 6-1。计算欧拉数 e

`import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

class CalculateE
{
   final static int LASTITER = 17;
   public static void main(String[] args)
   {
      ExecutorService executor = Executors.newFixedThreadPool(1);
      Callable callable;
      callable = new Callable()
                 {
                    public BigDecimal call()
                    {
                       MathContext mc = new MathContext(100,
                                                        RoundingMode.HALF_UP);
                       BigDecimal result = BigDecimal.ZERO;
                       for (int i = 0; i <= LASTITER; i++)
                       {
                          BigDecimal factorial = factorial(new BigDecimal(i));
                          BigDecimal res = BigDecimal.ONE.divide(factorial, mc);
                          result = result.add(res);
                       }
                       return result;                     }
                    public BigDecimal factorial(BigDecimal n)
                    {
                       if (n.equals(BigDecimal.ZERO))
                          return BigDecimal.ONE;
                       else
                          return n.multiply(factorial(n.subtract(BigDecimal.ONE)));
                    }
                 };
      Future taskFuture = executor.submit(callable);
      try
      {
         while (!taskFuture.isDone())
            System.out.println("waiting");
         System.out.println(taskFuture.get());
      }
      catch(ExecutionException ee)
      {
         System.err.println("task threw an exception");
         System.err.println(ee);
      }
      catch(InterruptedException ie)
      {
         System.err.println("interrupted while waiting");
      }
      executor.shutdownNow();
   }
}`

执行清单 6-1 的main()方法的主线程首先通过调用Executors ' newFixedThreadPool()方法获得一个执行器。然后,它实例化一个实现了Callable的匿名类,并将这个任务提交给执行器,接收一个Future实例作为响应。

提交任务后,线程通常会做一些其他工作,直到它需要获得任务的结果。我选择通过让主线程重复输出等待消息来模拟这项工作,直到Future实例的isDone()方法返回 true。(在实际应用中,我会避免这种循环。)此时,主线程调用实例的get()方法来获得结果,然后输出结果。

images 注意务必在执行程序完成后将其关闭;否则,应用可能不会结束。应用通过调用shutdownNow()来完成这项任务。

callable 的call()方法通过计算数学幂级数 e = 1/0 来计算 e!+1/1!+1/2!+….这个数列可以通过求和 1/ n 来求值!,其中 n 的范围从 0 到无穷大。

call()首先实例化java.math.MathContext封装一个精度(位数)和一个舍入方式。我选择 100 作为 e 的精度上限,选择HALF_UP作为舍入模式。

images 提示增加精度以及LASTITER的值,使级数收敛到更长更精确的 e 的近似值

call()接下来将名为result的局部变量java.math.BigDecimal初始化为BigDecimal.ZERO。然后它进入一个循环,计算阶乘,用阶乘除BigDecimal.ONE,并将除法结果加到result

divide()方法将MathContext实例作为其第二个参数,以确保除法不会导致非终止的十进制扩展(除法的商结果无法精确表示,例如 0.3333333……),这会抛出java.lang.ArithmeticException(提醒调用者商无法精确表示的事实),执行器会将其重新抛出为ExecutionException

当您运行此应用时,您应该观察到类似如下的输出:

waiting
waiting
waiting
waiting
2.71828182845904507051604779584860506117897963525103269890073500406522504250484331405588797434
4245741730039454062711

同步器

线程 API 提供了同步原语,用于同步线程对临界区的访问。因为很难正确编写基于这些原语的同步代码,所以并发工具包括同步器,这些类有助于常见形式的同步。

五种常用的同步器是倒计时锁、循环屏障、交换器、移相器和信号机:

  • 一个倒计时锁存器让一个或多个线程在一个“门”等待,直到另一个线程打开这个门,此时这些其他线程可以继续。java.util.concurrent.CountDownLatch类实现了这个同步器。
  • 一个循环障碍让一组线程相互等待到达一个共同的障碍点java.util.concurrent.CyclicBarrier类实现了这个同步器,并利用了java.util.concurrent.BrokenBarrierException类。CyclicBarrier实例在应用中非常有用,这些应用包含固定大小的线程,它们偶尔会互相等待。CyclicBarrier支持一个可选的Runnable,称为屏障动作,它在团队中最后一个线程到达之后,任何线程被释放之前,在每个屏障点运行一次。这个屏障动作对于在任何一方继续之前更新共享状态是有用的。
  • 一个交换器让一对线程在同步点交换对象。java.util.concurrent.Exchanger类实现了这个同步器。每个线程在进入Exchangerexchange()方法时提供一些对象,与一个伙伴线程匹配,并在返回时接收其伙伴的对象。交换器可能在遗传算法(见[en.wikipedia.org/wiki/Genetic_algorithm](http://en.wikipedia.org/wiki/Genetic_algorithm))和管道设计等应用中有用。
  • 相位器是一个可重复使用的同步屏障,其功能类似于CyclicBarrierCountDownLatch,但提供了更多的灵活性。例如,与其他屏障不同,在相位器上注册同步的线程数量可能随时间而变化。java.util.concurrent.Phaser类实现了这个同步器。Phaser可以用来代替CountDownLatch来控制为可变数量的团体服务的一次性动作。它也可以被在 Fork/Join 框架的上下文中执行的任务使用,这将在本章后面讨论。
  • 一个信号量维护一组许可,用于限制可以访问有限资源的线程数量。java.util.concurrent.Semaphore类实现了这个同步器。如果有必要,对Semaphoreacquire()方法之一的每个调用都会被阻塞,直到获得许可,然后获取它。每次调用release()都会添加一个许可,潜在地释放一个阻塞的收购方。然而,没有使用实际的许可对象;Semaphore实例只记录可用许可的数量,并相应地采取行动。Semaphore通常用于限制可以访问某些(物理或逻辑)资源的线程数量。

考虑一下CountDownLatch类。它的每个实例都被初始化为非零计数。一个线程调用CountDownLatchawait()方法之一来阻塞,直到计数达到零。另一个线程调用CountDownLatchcountDown()方法来减少计数。一旦计数达到零,等待线程就被允许继续。

images 注意等待线程释放后,对await()的后续调用立即返回。此外,因为计数不能被重置,所以一个CountDownLatch实例只能被使用一次。当需要重复使用时,请使用CyclicBarrier类。

我们可以使用CountDownLatch来确保工作线程几乎同时开始工作。例如,看看清单 6-2 中的。

清单 6-2。使用倒计时锁存器触发协调启动

`import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class CountDownLatchDemo
{
   final static int NTHREADS = 3;
   public static void main(String[] args)
   {
      final CountDownLatch startSignal = new CountDownLatch(1);
      final CountDownLatch doneSignal = new CountDownLatch(NTHREADS);
      Runnable r = new Runnable()
                   {
                      public void run()
                      {                          try
                         {
                            report("entered run()");
                            startSignal.await(); // wait until told to proceed
                            report("doing work");
                            Thread.sleep((int)(Math.random()*1000));
                            doneSignal.countDown(); // reduce count on which
                                                    // main thread is waiting
                         }
                         catch (InterruptedException ie)
                         {
                            System.err.println(ie);
                         }
                      }
                      void report(String s)
                      {
                         System.out.println(System.currentTimeMillis()+": "+
                                            Thread.currentThread()+": "+s);
                      }
                   };
      ExecutorService executor = Executors.newFixedThreadPool(NTHREADS);
      for (int i = 0; i < NTHREADS; i++)
         executor.execute(r);
      try
      {
         System.out.println("main thread doing something");
         Thread.sleep(1000);      // sleep for 1 second
         startSignal.countDown(); // let all threads proceed
         System.out.println("main thread doing something else");
         doneSignal.await();      // wait for all threads to finish
         executor.shutdownNow();
      }
      catch (InterruptedException ie)
      {
         System.err.println(ie);
      }
   }
}`

清单 6-2 的主线程首先创建一对倒计时闩锁。倒计时闩锁阻止任何工作线程继续运行,直到主线程准备好让它们继续运行。doneSignal倒计时闩锁导致主线程等待,直到所有工作线程完成。

主线程接下来创建一个 runnable,它的run()方法由随后创建的工作线程执行。

run()方法首先输出一个初始消息,然后调用startSignalawait()方法,等待这个倒计时锁存器的计数读到零,然后才能继续。一旦发生这种情况,run()输出一条消息,表明工作正在进行,并休眠一段随机的时间(0 到 999 毫秒)来模拟这项工作。

此时,run()调用doneSignalcountDown()方法来减少这个锁存器的计数。一旦该计数达到零,等待该信号的主线程将继续,关闭执行器并终止应用。

创建 runnable 后,主线程获得一个基于NTHREADS线程的线程池的执行器,然后调用执行器的execute()方法NTHREADS次,将 runnable 传递给每个基于NTHREADS池的线程。这个动作启动进入run()的工作线程。

接下来,主线程输出一条消息并休眠一秒钟,以模拟做额外的工作(给所有工作线程一个进入run()并调用startSignal.await()的机会),调用startSignalcountdown()方法以使工作线程开始运行,输出一条消息以指示它正在做其他事情,并调用doneSignalawait()方法以等待这个倒计时锁的计数达到零,然后才能继续。

当您运行此应用时,您将会看到类似如下的输出:

main thread doing something
1312936533890: Thread[pool-1-thread-1,5,main]: entered run()
1312936533890: Thread[pool-1-thread-2,5,main]: entered run()
1312936533890: Thread[pool-1-thread-3,5,main]: entered run()
1312936534890: Thread[pool-1-thread-1,5,main]: doing work
1312936534890: Thread[pool-1-thread-2,5,main]: doing work
1312936534890: Thread[pool-1-thread-3,5,main]: doing work
main thread doing something else

您可能会看到在最后一条“entered run()”消息和第一条“doing work”消息之间出现了main thread doing something else消息。

images 为了简洁,我避免了演示CyclicBarrierExchangerPhaserSemaphore的例子。相反,我建议您参考这些类的 Java 文档。每个类的文档都提供了一个示例,向您展示如何使用该类。

并发收款

java.util.concurrent包包括几个接口和类,它们是集合框架的面向并发的扩展(参见第五章):

  • BlockingDequeBlockingQueuejava.util.Deque的子接口,它也支持阻塞操作,在检索元素之前等待队列变为非空,在存储元素之前等待队列中的空间变得可用。LinkedBlockingDeque类实现了这个接口。
  • BlockingQueuejava.util.Queue的子接口,也支持阻塞操作,即在检索元素之前等待队列变为非空,在存储元素之前等待队列中的空间变得可用。每个ArrayBlockingQueueDelayQueueLinkedBlockingDequeLinkedBlockingQueueLinkedTransferQueuePriorityBlockingQueueSynchronousQueue类都实现了这个接口。
  • ConcurrentMapjava.util.Map的子接口,它声明了附加的原子putIfAbsent()remove()replace()方法。ConcurrentHashMap类(并发等价于java.util.HashMap)和ConcurrentSkipListMap类实现了这个接口。
  • ConcurrentNavigableMapConcurrentMapjava.util.NavigableMap的子接口。ConcurrentSkipListMap类实现了这个接口。
  • TransferQueueBlockingQueue的子接口,描述了一个阻塞队列,生产者可以在其中等待消费者接收元素。LinkedTransferQueue类实现了这个接口。
  • ConcurrentLinkedDeque是基于链接节点的无界并发队列。
  • ConcurrentLinkedQueueQueue接口的无界线程安全 FIFO 实现。
  • ConcurrentSkipListSet是一个可伸缩的并发NavigableSet实现。
  • CopyOnWriteArrayListjava.util.ArrayList的线程安全变体,其中所有的变异(不可交换)操作(添加、设置等等)都是通过制作底层数组的新副本来实现的。
  • CopyOnWriteArraySet是一个java.util.Set实现,它的所有操作都使用一个内部CopyOnWriteArrayList实例。

清单 6-3 在清单 4-27 的生产者-消费者应用(PC)中使用了BlockingQueueArrayBlockingQueue

清单 6-3。阻塞队列相当于清单 4-27 的PC应用

`import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class PC
{
   public static void main(String[] args)
   {
      final BlockingQueue bq;
      bq = new ArrayBlockingQueue(26);
      final ExecutorService executor = Executors.newFixedThreadPool(2);
      Runnable producer;
      producer = new Runnable()
                 {
                    public void run()
                    {
                       for (char ch = 'a'; ch <= 'z'; ch++)
                       {
                          try
                          {
                             bq.put(ch);
                             System.out.println(ch+" produced by producer.");
                          }
                          catch (InterruptedException ie)                           {
                             assert false;
                          }
                       }
                    }
                 };
      executor.execute(producer);
      Runnable consumer;
      consumer = new Runnable()
                 {
                    public void run()
                    {
                       char ch = '\0';
                       do
                       {
                          try
                          {
                             ch = bq.take();
                             System.out.println(ch+" consumed by consumer.");
                          }
                          catch (InterruptedException ie)
                          {
                             assert false;
                          }
                       }
                       while (ch != 'z');
                       executor.shutdownNow();
                    }
                 };
      executor.execute(consumer);
   }
}`

清单 6-3 分别使用BlockingQueueput()take()方法将一个对象放入阻塞队列和从阻塞队列中移除一个对象。put()没有空间放物体时遮挡;take()队列为空时阻塞。

虽然BlockingQueue确保了一个字符在产生之前不会被消耗,但是这个应用的输出可能会有不同的指示。例如,下面是一次运行的部分输出:

Y consumed by consumer.
Y produced by producer.
Z consumed by consumer.
Z produced by producer.

第四章的PC应用通过引入围绕setSharedChar() / System.out.println()的额外同步层和围绕getSharedChar() / System.out.println()的额外同步层,克服了这种不正确的输出顺序。下一节将向您展示锁形式的另一种选择。

java.util.concurrent.locks包提供了用于锁定和等待条件的接口和类,其方式不同于内置的同步和监视器。

这个包最基本的锁接口是Lock,它提供了比通过synchronized保留字所能实现的更广泛的锁操作。Lock还通过关联的Condition对象支持等待/通知机制。

images 注意与线程进入临界区时获得的隐式锁相比,Lock对象的最大优势在于它们能够退出获取锁的尝试。例如,当锁不能立即使用或者超时过期(如果指定的话)时,tryLock()方法就会退出。另外,当另一个线程在获取锁之前发送中断时,lockInterruptibly()方法退出。

ReentrantLock实现了Lock,描述了一个可重入的互斥Lock实现,具有与通过synchronized访问的隐式监控锁相同的基本行为和语义,但是具有扩展的功能。

清单 6-4 展示了一个版本的清单 6-3 中的LockReentrantLock,确保输出不会以错误的顺序显示(一个消费的消息出现在一个产生的消息之前)。

清单 6-4。实现锁的同步

`import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class PC
{
   public static void main(String[] args)
   {
      final Lock lock = new ReentrantLock();
      final BlockingQueue bq;
      bq = new ArrayBlockingQueue(26);
      final ExecutorService executor = Executors.newFixedThreadPool(2);
      Runnable producer;
      producer = new Runnable()
                 {
                    public void run()
                    {
                       for (char ch = 'a'; ch <= 'z'; ch++)
                       {
                          try {
                             lock.lock();
                             try
                             {
                                while (!bq.offer(ch))
                                {
                                   lock.unlock();
                                   Thread.sleep(50);
                                   lock.lock();
                                }
                                System.out.println(ch+" produced by producer.");
                             }
                             catch (InterruptedException ie)
                             {
                                assert false;
                             }                           }
                          finally
                          {
                             lock.unlock();
                          }
                       }
                    }
                 };
      executor.execute(producer);
      Runnable consumer;
      consumer = new Runnable()
                 {
                    public void run()
                    {
                       char ch = '\0';
                       do
                       {
                          try
                          {
                             lock.lock();
                             try
                             {
                                Character c;
                                while ((c = bq.poll()) == null)
                                {
                                   lock.unlock();
                                   Thread.sleep(50);
                                   lock.lock();
                                }
                                ch = c; // unboxing behind the scenes
                                System.out.println(ch+" consumed by consumer.");
                             }
                             catch (InterruptedException ie)
                             {
                                assert false;
                             }
                          }
                          finally
                          {
                             lock.unlock();
                          }
                       }
                       while (ch != 'z');
                       executor.shutdownNow();
                    }
                 };
      executor.execute(consumer);
   }
}`

清单 6-4 使用Locklock()unlock()方法来获取和释放一个锁。当线程调用lock()并且锁不可用时,线程被禁用(并且不能被调度),直到锁变得可用。

这个清单还使用了BlockingQueueoffer()方法而不是put()方法在阻塞队列中存储一个对象,使用了poll()方法而不是take()方法从队列中检索一个对象。使用这些替代方法是因为它们不会阻塞。

如果我使用了put()take(),这个应用将会在下面的场景中死锁:

  1. 消费者线程通过它的lock.lock()调用获得锁。
  2. 生产者线程试图通过它的lock.lock()调用获取锁,但被禁用,因为消费者线程已经获取了锁。
  3. 消费者线程调用take()从队列中获取下一个java.lang.Character对象。
  4. 因为队列是空的,所以使用者线程必须等待。
  5. 消费者线程在等待之前不会放弃生产者线程需要的锁,因此生产者线程也继续等待。

images 注意如果我可以访问BlockingQueue实现使用的私有锁,我会使用put()take(),也会在那个锁上调用Locklock()unlock()方法。由此产生的应用将与清单 4-27 的PC应用完全相同(从锁的角度来看),其中生产者线程和消费者线程各使用了两次synchronized

运行这个应用,你会发现它生成了与清单 4-27 的PC应用相同的输出。

原子变量

java.util.concurrent.atomic包提供了以Atomic为前缀的类(例如AtomicLong),支持对单个变量进行无锁、线程安全的操作。每个类都声明了像get()set()这样的方法来读写这个变量,而不需要外部同步。

清单 4-23 声明了一个名为ID的小工具类,用于通过IDgetNextID()方法返回唯一的长整数标识符。因为此方法不是同步的,所以多个线程可以获得相同的标识符。清单 6-5 通过在方法头中包含保留字synchronized解决了这个问题。

清单 6-5。通过synchronized 以线程安全的方式返回唯一标识符

class ID
{
   private static long nextID = 0;
   static synchronized long getNextID()
   {
      return nextID++;
   }
}

虽然synchronized适合这个类,但是在更复杂的类中过度使用这个保留字会导致死锁、饥饿或其他问题。清单 6-6 向您展示了如何通过用原子变量替换synchronized来避免对并发应用的活性(及时执行的能力)的攻击。

清单 6-6。通过AtomicLong 以线程安全的方式返回唯一 id

import java.util.concurrent.atomic.AtomicLong;

class ID
{
   private static AtomicLong nextID = new AtomicLong(0);
   static long getNextID()
   {
      return nextID.getAndIncrement();
   }
}

在清单 6-6 中,我已经将nextIDlong转换为AtomicLong实例,并将该对象初始化为 0。我还重构了getNextID()方法来调用AtomicLonggetAndIncrement()方法,该方法将AtomicLong实例的内部长整型变量递增 1,并在一个不可分割的步骤中返回先前的值。

附加并发工具

除了支持 Java 5 引入的并发工具之外,Java 7 还引入了一对提高性能的并发工具,这在一定程度上是通过充分利用多个处理器/内核来实现的。这些工具由java.util.concurrent.ThreadLocalRandom类和 Fork/Join 框架组成。

线程局部随机

ThreadLocalRandom类描述了一个独立于当前线程的随机数生成器。换句话说,只能从当前线程访问它。

java.lang.Math类使用的全局随机数生成器一样(我将在本章后面讨论),一个ThreadLocalRandom实例用一个内部生成的种子(起始值)初始化,否则不能修改。当适用时,在并发程序中使用ThreadLocalRandom而不是调用Math.random()通常会导致更少的开销和争用。

要使用这个类,首先调用ThreadLocalRandomstatic ThreadLocalRandom current()方法来返回当前线程的ThreadLocalRandom实例。继续调用ThreadLocalRandomnext方法之一,比如double nextDouble(double n),它返回一个伪随机的、均匀分布的double值,介于 0(含)和指定值n(不含)之间。传递给n的参数是要返回的随机数的上限,并且必须是正数;否则抛出IllegalArgumentException

以下示例通过另一个“next”方法进行了演示:

int r = ThreadLocalRandom.current().nextInt(20, 40);

这个例子调用ThreadLocalRandomint nextInt(int least, int bound)方法返回一个伪随机的、均匀分布的值,该值在给定的least值(包含)和bound(不包含)之间。在这个例子中,least20,它是可以返回的最小值,bound 是40,它比可以返回的最大值(39)大一个整数。

images 注意 ThreadLocalRandom利用线程局部变量,我在第四章对 Java 线程 API 的介绍中讨论过。

分叉/连接框架

代码总是需要更快地执行。历史上,这种需求是通过提高微处理器速度和/或支持多处理器来解决的。然而,在 2003 年左右,由于自然限制,微处理器速度停止增长。为了弥补这一点,处理器制造商开始在处理器中添加多个处理内核,通过大规模并行来提高速度。

images 并行是指通过多个处理器和内核的某种组合来同时运行线程/任务。相比之下,并发是一种更广义的并行形式,其中线程通过任务切换同时运行或看起来同时运行,也称为虚拟并行。有些人进一步将并发性描述为程序或操作系统的属性,将并行性描述为同时执行多个任务的运行时行为。

Java 通过线程 API 和并发工具(如线程池)支持并发。并发的问题是它不能最大限度地利用可用的处理器/内核资源。例如,假设您创建了一个排序算法,该算法将数组分成两半,分配两个线程对每一半进行排序,并在两个线程完成后合并结果。

让我们假设每个线程运行在不同的处理器上。因为数组的每一半中可能出现不同数量的元素重新排序,所以一个线程可能会在另一个线程之前完成,并且必须在合并发生之前等待。在这种情况下,浪费了处理器资源。

这个问题(以及代码冗长和难以阅读的相关问题)可以通过递归地将任务分解成子任务并组合结果来解决。这些子任务并行运行,并且几乎同时完成(如果不是同时完成的话),它们的结果被合并,并通过堆栈传递到前一层子任务。等待几乎不会浪费任何处理器时间,递归代码也不那么冗长,而且(通常)更容易理解。Java 提供了 Fork/Join 框架来实现这个场景。

Fork/Join 由一个特殊的执行器服务和线程池组成。executor 服务使一个任务对框架可用,这个任务被分解成更小的任务,这些任务从池中被分叉(由不同的线程执行)。一个任务一直等到加入(其子任务完成)。

Fork/Join 使用工作窃取来最小化线程争用和开销。工作线程池中的每个工作线程都有自己的双端工作队列,并将新任务推送到该队列。它从队列的头部读取任务。如果队列为空,工作线程会尝试从另一个队列的尾部获取任务。窃取并不常见,因为工作线程按照后进先出(LIFO)的顺序将任务放入队列中,并且随着问题被分成子问题,工作项的大小会变得更小。你开始把任务交给一个中心工作人员,它继续把它们分成更小的任务。最终所有的工人都与最小同步有关。

Fork/Join 主要由java.util.concurrent包的ForkJoinPoolForkJoinTaskForkJoinWorkerThreadRecursiveActionRecursiveTask类组成:

  • ForkJoinPool是运行ForkJoinTaskExecutorService实现。一个ForkJoinPool实例为来自非ForkJoinTask客户端的提交提供入口点,并提供管理和监控操作。
  • ForkJoinTask是在ForkJoinPool上下文中运行的任务的抽象基类。一个ForkJoinTask实例是一个类似线程的实体,它比普通线程要轻得多。在一个ForkJoinPool中,大量的任务和子任务可能由少量的实际线程托管,代价是一些使用限制。
  • ForkJoinWorkerThread描述一个由ForkJoinPool实例管理的线程,它执行ForkJoinTask s
  • RecursiveAction描述一个递归的无结果ForkJoinTask
  • RecursiveTask描述了一个递归的结果承载ForkJoinTask

Java 文档提供了基于RecursiveAction的任务(比如排序)和基于RecursiveTask的任务(比如计算斐波那契数)的例子。你也可以使用RecursiveAction来完成矩阵乘法(见[en.wikipedia.org/wiki/Matrix_multiplication](http://en.wikipedia.org/wiki/Matrix_multiplication))。

例如,假设你已经创建了清单 6-7 的Matrix类来表示由特定数量的行和列组成的矩阵。

清单 6-7。一个表示二维表格的类

class Matrix
{
   private double[][] matrix;
   Matrix(int nrows, int ncols)
   {
      matrix = new double[nrows][ncols];
   }
   int getCols()
   {
      return matrix[0].length;
   }
   int getRows()
   {
      return matrix.length;
   }
   double getValue(int row, int col)
   {
      return matrix[row][col];
   }
   void setValue(int row, int col, double value)
   {
      matrix[row][col] = value;
   }
}

清单 6-8 展示了将两个Matrix实例相乘的单线程方法:

清单 6-8。通过标准矩阵乘法算法将两个Matrix实例相乘

class MatMult {    public static void main(String[] args)    {       Matrix a = new Matrix(1, 3);       a.setValue(0, 0, 1); // | 1 2 3 |       a.setValue(0, 1, 2);       a.setValue(0, 2, 3);       dump(a);       Matrix b = new Matrix(3, 2);       b.setValue(0, 0, 4); // | 4 7 |       b.setValue(1, 0, 5); // | 5 8 |       b.setValue(2, 0, 6); // | 6 9 |       b.setValue(0, 1, 7);       b.setValue(1, 1, 8);       b.setValue(2, 1, 9);       dump(b);       dump(multiply(a, b));    }    static void dump(Matrix m)    {       for (int i = 0; i < m.getRows(); i++)       {          for (int j = 0; j < m.getCols(); j++)             System.out.print(m.getValue(i, j)+" ");          System.out.println();       }       System.out.println();    }    static Matrix multiply(Matrix a, Matrix b)    {       if (a.getCols() != b.getRows())          throw new IllegalArgumentException("rows/columns mismatch");       Matrix result = new Matrix(a.getRows(), b.getCols());       for (int i = 0; i < a.getRows(); i++)          for (int j = 0; j < b.getCols(); j++)             for (int k = 0; k < a.getCols(); k++)                result.setValue(i, j, result.getValue(i, j)+a.getValue(i, k)*                                b.getValue(k, j));       return result;    } }

清单 6-8 的MatMult类声明了一个演示矩阵乘法的multiply()方法。在验证了第一个Matrix ( a)中的列数等于第二个Matrix ( b)中的行数(这对于算法来说是必不可少的)之后,multiply()创建一个名为resultMatrix,并进入一系列嵌套循环来执行乘法。

这些循环的本质如下:对于a中的每一行,将该行的每一列值乘以b中相应列的行值。将乘法的结果相加,并将总数存储在通过a中的行索引(i)和b中的列索引(j)指定的位置的result中。

当您运行此应用时,它会生成以下输出,这表明 1 行乘 3 列的矩阵乘以 3 行乘 2 列的矩阵会得到 1 行乘 2 列的矩阵:

1.0 2.0 3.0

4.0 7.0
5.0 8.0
6.0 9.0

32.0 50.0

计算机科学家将这种算法归类为 O(n 3 ),读作“n 次方的 big-oh”或“近似 n 次方”。这种符号是对算法性能进行分类的一种抽象方式(不会陷入具体细节,如微处理器速度)。O(n 3 分类表示性能非常差,并且这种性能随着被相乘的矩阵的大小增加而恶化。

通过将每个逐行逐列的乘法任务分配给单独的类似线程的实体,可以提高性能(在多处理器和/或多核平台上)。清单 6-9 向您展示了如何在 Fork/Join 框架的上下文中完成这个场景。

清单 6-9。通过 Fork/Join 框架将两个矩阵相乘

`import java.util.ArrayList;
import java.util.List;

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.RecursiveAction;

class MatMult extends RecursiveAction
{
   private Matrix a, b, c;
   private int row;
   MatMult(Matrix a, Matrix b, Matrix c)
   {
      this(a, b, c, -1);
   }
   MatMult(Matrix a, Matrix b, Matrix c, int row)
   {
      if (a.getCols() != b.getRows())
         throw new IllegalArgumentException("rows/columns mismatch");
      this.a = a;
      this.b = b;
      this.c = c;
      this.row = row;
   }
   @Override
   public void compute()
   {
      if (row == -1)
      {
         List tasks = new ArrayList<>();
         for (int row = 0; row < a.getRows(); row++)
            tasks.add(new MatMult(a, b, c, row));
         invokeAll(tasks);
      }
      else
         multiplyRowByColumn(a, b, c, row);
   }
   static void multiplyRowByColumn(Matrix a, Matrix b, Matrix c, int row)
   {
      for (int j = 0; j < b.getCols(); j++)
         for (int k = 0; k < a.getCols(); k++)
            c.setValue(row, j, c.getValue(row, j)+a.getValue(row, k)*
                       b.getValue(k, j));
   }
   static void dump(Matrix m)
   {
      for (int i = 0; i < m.getRows(); i++)
      {
         for (int j = 0; j < m.getCols(); j++)
            System.out.print(m.getValue(i, j)+" ");          System.out.println();
      }
      System.out.println();
   }
   public static void main(String[] args)
   {
      Matrix a = new Matrix(2, 3);
      a.setValue(0, 0, 1); // | 1 2 3 |
      a.setValue(0, 1, 2); // | 4 5 6 |
      a.setValue(0, 2, 3);
      a.setValue(1, 0, 4);
      a.setValue(1, 1, 5);
      a.setValue(1, 2, 6);
      dump(a);
      Matrix b = new Matrix(3, 2);
      b.setValue(0, 0, 7); // | 7 1 |
      b.setValue(1, 0, 8); // | 8 2 |
      b.setValue(2, 0, 9); // | 9 3 |
      b.setValue(0, 1, 1);
      b.setValue(1, 1, 2);
      b.setValue(2, 1, 3);
      dump(b);
      Matrix c = new Matrix(2, 2);
      ForkJoinPool pool = new ForkJoinPool();
      pool.invoke(new MatMult(a, b, c));
      dump(c);
   }
}`

清单 6-9 展示了一个扩展了RecursiveActionMatMult类。为了完成有意义的工作,RecursiveActionvoid compute()方法被覆盖。

images 注意虽然compute()通常用于递归地将任务细分为子任务,但我选择以不同的方式处理乘法任务(为了简洁和简单)。

在创建了Matrix es ab之后,清单 6-9 的main()方法创建了Matrix c并实例化了ForkJoinPool。然后实例化MatMult,将这三个Matrix实例作为参数传递给MatMult(Matrix a, Matrix b, Matrix c)构造函数,并调用ForkJoinPoolT invoke(ForkJoinTask<T> task)方法开始运行这个初始任务。在初始任务及其所有子任务完成之前,此方法不会返回。

MatMult(Matrix a, Matrix b, Matrix c)构造函数调用MatMult(Matrix a, Matrix b, Matrix c, int row)构造函数,将-1指定为row的值。作为前面提到的invoke()方法调用的结果,compute()使用这个值来区分初始任务和子任务。

当最初调用compute()时(row等于-1,它创建一个MatMult任务的List,并将这个List传递给RecursiveActionCollection<T> invokeAll(Collection<T> tasks)方法(继承自ForkJoinTask)。这个方法派生出所有List集合的任务,这些任务将开始执行。然后它等待直到invokeAll()方法返回(也加入到所有这些任务中),当boolean isDone()方法(也继承自ForkJoinTask)为每个任务返回 true 时,就会发生这种情况。

注意tasks.add(new MatMult(a, b, c, row));方法调用。这个调用为一个MatMult实例分配一个特定的row值。当invokeAll()被调用时,每个任务的compute()方法被调用,并检测分配给row的不同值(不是-1)。然后,它为其特定的row执行multiplyRowByColumn(a, b, c, row);

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

1.0 2.0 3.0
4.0 5.0 6.0

7.0 1.0
8.0 2.0
9.0 3.0

50.0 14.0
122.0 32.0

物体

Java 7 的新java.util.Objects类由操作对象的类方法组成。这些实用工具包括用于比较两个对象、计算对象的哈希代码、要求引用不能为空以及返回对象的字符串的空安全或空容忍方法。

表 6-3 描述了Objects'类的方法。

image

image

Objects在调用所提供的Comparator之前,通过使用==实现空容忍的compare()方法来首先比较其对象标识的参数。

equals()deepEquals()方法定义了对象引用的等价关系。与Object.equals(Object o)不同,Objects.equals(Object a, Object b)处理空值,当两个参数都为空时返回 true,或者当第一个参数为非空并且a.equals(b)返回 true。

在数组(包括嵌套数组)的上下文中使用deepEquals()方法来确定两个数组是否深度相等(它们都为空或者它们包含相同数量的元素,并且两个数组中所有对应的元素对都深度相等)。

当以下任何条件成立时,该方法的两个(可能为空)参数(由下面的e1e2表示)完全相等:

  • e1e2是对象引用类型的数组,Arrays.deepEquals(e1, e2)将返回 true
  • e1e2是相同原始类型的数组,Arrays.equals(e1, e2)的适当重载将返回 true。
  • e1 == e2
  • e1.equals(e2)会返回 true。

平等意味着深度平等,但反过来就不一定了。在以下示例中,xy完全相等,但不相等:

Object common = "string";
Object[] x = {"string"};
Object[] y = {"string"};
System.out.println("x == y: "+(x == y)); // false (two different references)
System.out.println("Objects.equals(x, y): "+Objects.equals(x, y)); // false
System.out.println("Objects.deepEquals(x, y): "+Objects.deepEquals(x, y)); // true

数组xy不相等,因为它们包含两个不同的引用,而Objects.equals()在这个上下文中使用引用相等(比较它们的引用)。(当一个类覆盖了Objectequals()方法时,就会出现对象相等,或者比较对象内容。)然而,这些数组完全相等,因为xy都是对象引用类型的数组,并且Arrays.deepEquals(x, y)将返回 true。

images 注意java.lang.Object类不同,由于其前缀java.lang而被自动导入,当您想要避免指定java.util前缀时,您必须显式地将Objects导入到您的源代码(import java.util.Objects;)中。

requireNonNull()方法的 Java 文档指出,它们主要是为在方法和构造函数中进行参数验证而设计的。其思想是在试图在方法或构造函数中使用这些引用之前,检查方法或构造函数的参数值是否为空引用,并避免潜在的NullPointerException s. 清单 6-10 提供了一个演示。

清单 6-10。测试空引用参数的构造函数参数

`import java.util.Objects;

class Employee
{
   private String firstName, lastName;
   Employee(String firstName, String lastName)
   {
      try
      {
         firstName = Objects.requireNonNull(firstName);
         lastName = Objects.requireNonNull(lastName,
                                           "lastName shouldn't be null");
         lastName = Character.toUpperCase(lastName.charAt(0))+
                    lastName.substring(1);
         this.firstName = firstName;
         this.lastName = lastName;       }
      catch (NullPointerException npe)
      {
         // In lieu of a more sophisticated logging mechanism, and also for
         // brevity, I output the exception's message to standard output.
         System.out.println(npe.getMessage());
      }
   }
   String getName()
   {
      return firstName+" "+lastName;
   }
   public static void main(String[] args)
   {
      Employee e1 = new Employee(null, "doe");
      Employee e2 = new Employee("john", null);
      Employee e3 = new Employee("john", "doe");
      System.out.println(e3.getName());
   }
}`

清单 6-10 的Employee构造函数首先调用传递给其firstNamelastName参数的每个参数值的Objects.requireNonNull()。如果任一参数值为空引用,NullPointerException被实例化并抛出;否则,requireNonNull()方法将返回参数值,该值保证为非空。

现在调用lastName.charAt()是安全的,它从调用该方法的字符串中返回第一个字符。该字符被传递给CharactertoUpperCase()工具方法,当它不代表小写字母或小写字母的大写等价物时,该方法返回该字符。在toUpperCase()返回之后,字母(可能是大写的)被加到字符串的其余部分之前,导致姓氏以大写字母开始。(假设名称仅由字母组成。)

清单 6-10 的Objects.requireNonNull()方法调用为下面的例子提供了一个更简洁的替代方案,它演示了requireNonNull(T obj, String message)message参数是如何使用的:

if (firstName == null)
   throw new NullPointerException();
if (lastName == null)
   throw new NullPointerException("lastname shouldn't be null");

编译清单 6-10 ( javac Employee.java)并运行结果应用(java Employee)。您应该观察到以下输出:

null
lastName shouldn't be null
John Doe

如清单 6-10 所示,Objects类的方法被引入是为了通过降低NullPointerException被意外抛出的可能性来提高空安全性。作为另一个例子,Employee e = null; String s = e.toString();导致抛出NullPointerException实例,因为您不能对存储在e中的空引用调用toString()。相反,Employee e = null; String s = Objects.toString(e);不会导致抛出NullPointerException实例,因为Objects.toString()在检测到e包含空引用时会返回"null"。不需要像在if (e != null) { String s = e.toString(); /* other code here */ }中那样让显式测试一个引用是否为空,您可以将空检查卸载到Objects类的各种方法中。

引入这些方法也是为了避免“重新发明轮子”综合症。许多开发人员重复编写了执行类似操作的方法,但都是以空安全的方式进行的。Java 的标准类库中包含了Objects规范了这种常见的功能。

随机

第四章给你介绍了Math类的random()方法。如果您要研究这个方法的源代码,您会发现下面的实现:

private static Random randomNumberGenerator;
private static synchronized Random initRNG()
{
   Random rnd = randomNumberGenerator;
   return (rnd == null) ? (randomNumberGenerator = new Random()) : rnd;
}
public static double random()
{
   Random rnd = randomNumberGenerator;
   if (rnd == null) rnd = intRNG();
   return rnd.nextDouble();
}

这个实现展示了惰性初始化(直到第一次需要时才初始化,以提高性能),向您展示了Mathrandom()方法是根据一个名为Random的类实现的,该类位于java.util包中。Random实例生成随机数序列,被称为随机数生成器

images 注意这些数字不是真正随机的,因为它们是由数学算法生成的。因此,它们通常被称为伪随机数。然而,去掉“伪”前缀并把它们称为随机数通常是很方便的。

Random从一个被称为种子的特殊 48 位值开始,生成它的随机数序列。该值随后通过数学算法进行修改,该算法被称为线性同余发生器

images 查看维基百科的“线性同余生成器”条目([en.wikipedia.org/wiki/Linear_congruential_generator](http://en.wikipedia.org/wiki/Linear_congruential_generator))了解这种生成随机数的算法。

Random声明了一对构造函数:

  • 创建一个新的随机数生成器。此构造函数将随机数生成器的种子设置为一个值,该值很可能不同于对此构造函数的任何其他调用。
  • Random(long seed)使用其seed参数创建一个新的随机数生成器。这个参数是随机数发生器内部状态的初始值,由protected int next(int bits)方法维护。

images 注意其他方法使用的next()方法是protected,这样子类可以改变生成器的实现,如下所示

protected int next(int bits) {
    long oldseed, nextseed;
    AtomicLong seed = this.seed;
    do {
       oldseed = seed.get();
       nextseed = (oldseed*multiplier+addend)&mask;
    } while (!seed.compareAndSet(oldseed, nextseed));
    return (int) (nextseed >>> (48-bits));
}

一些不同的东西。关于子类化的例子,请查看“子类化 Java . util . random”([www.javamex.com/tutorials/random_numbers/java_util_random_subclassing.shtml](http://www.javamex.com/tutorials/random_numbers/java_util_random_subclassing.shtml))。

因为Random()不接受seed参数,所以产生的随机数生成器总是生成不同的随机数序列。这解释了为什么每次应用开始运行时,Math.random()都会生成一个不同的序列。

images 提示 Random(long seed)让您有机会重用相同的种子值,允许生成相同的随机数序列。在调试包含随机数的错误应用时,您会发现这个功能非常有用。

Random(long seed)调用void setSeed(long seed)方法将种子设置为指定值。如果在实例化Random后调用setSeed(),随机数生成器将被重置为调用Random(long seed)后的状态。

前面的代码片段演示了Randomdouble nextDouble()方法,该方法返回随机数生成器序列中 0.0 到 1.0 之间的下一个伪随机、均匀分布的双精度浮点值。

Random还声明了以下方法来返回其他类型的值:

  • boolean nextBoolean()返回该随机数生成器序列中的下一个伪随机、均匀分布的布尔值。值 true 和 false 以(大约)相等的概率生成。
  • void nextBytes(byte[] bytes)生成伪随机字节整数值,并将它们存储在bytes数组中。生成的字节数等于bytes数组的长度。
  • float nextFloat()返回此随机数生成器序列中 0.0 到 1.0 之间的下一个伪随机、均匀分布的浮点值。
  • double nextGaussian()返回此随机数生成器序列中的下一个伪随机、高斯(“正态”)分布双精度浮点值,其平均值为 0.0,标准差为 1.0。
  • int nextInt()返回该随机数生成器序列中的下一个伪随机、均匀分布的整数值。所有 2 个 32 个可能的整数值以(近似)相等的概率产生。
  • int nextInt(int n)从该随机数生成器的序列中,返回一个介于 0(含)和指定值(不含)之间的伪随机、均匀分布的整数值。所有n可能的整数值都以(近似)相等的概率生成。
  • long nextLong()返回该随机数生成器序列中的下一个伪随机、均匀分布的长整数值。因为Random使用一个只有 48 位的种子,所以这个方法不会返回所有可能的 64 位长的整数值。

java.util.Collections类声明了一对用于混排列表内容的shuffle()方法。相比之下,Arrays类没有声明一个shuffle()方法来混洗数组的内容。清单 6-11 解决了这个遗漏。

清单 6-11。洗牌一个整数数组

`import java.util.Random;

class Shuffler
{
   public static void main(String[] args)
   {
      Random r = new Random();
      int[] array = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
      for (int i = 0; i < array.length; i++)
      {
         int n = r.nextInt(array.length);
         // swap array[i] with array[n]
         int temp = array[i];
         array[i] = array[n];
         array[n] = temp;       }
      for (int i = 0; i < array.length; i++)
         System.out.print(array[i]+" ");
      System.out.println();
   }
}`

清单 6-11 给出了一个简单的整数数组洗牌的方法——这个方法可以被推广。对于从数组开始到数组结束的每个数组条目,该条目与另一个条目交换,该条目的索引由int nextInt(int n)选择。

当您运行这个应用时,您将看到一个打乱的整数序列,类似于我观察到的以下序列:

7 1 5 2 9 8 6 4 3 0

练习

以下练习旨在测试您对并发工具ObjectsRandom的理解:

  1. Semaphore类的 Java 文档提供了一个Pool类,演示了信号量如何控制对项目池的访问。因为Pool是不完整的,所以引入一个单独的资源(用一个包含该资源的数组替换protected Object[] items = ...,然后在从SemaphoreDemo类的main()方法启动的一对线程的上下文中演示PoolgetItem()putItem()方法。

  2. Create an EqualsDemo application to play with Objects' deepEquals() method. As well as an EqualsDemo class, this application declares Car and Wheel classes. A Car instance contains (typically four) Wheel instances, and a Wheel instance contains a brand name. Each of Car and Wheel must override Object's equals() method but does not have to override hashCode() in this example. Your main() method should contain the following code and generate the output shown in the comments: Car[] cars1 = { new Car(4, "Goodyear"), new Car(4, "Goodyear") }; Car[] cars2 = { new Car(4, "Goodyear"), new Car(4, "Goodyear") }; Car[] cars3 = { new Car(4, "Michelin"), new Car(4, "Goodyear") }; Car[] cars4 = { new Car(3, "Goodyear"), new Car(4, "Goodyear") }; Car[] cars5 = { new Car(4, "Goodyear"), new Car(4, "Goodyear"),                 new Car(3, "Michelin") }; System.out.println(Objects.deepEquals(cars1, cars2)); // Output: true System.out.println(Objects.deepEquals(cars1, cars3)); // Output: false System.out.println(Objects.deepEquals(cars1, cars4)); // Output: false System.out.println(Objects.deepEquals(cars1, cars5)); // Output: false

    注释表明,当两个数组包含相同数量的相等元素时,它们是完全相等的。

  3. 创建一个Die应用,它使用Random来模拟骰子的角色(一个骰子)。输出值。

总结

Java 5 引入了并发工具来简化并发应用的开发。并发工具分为执行器、同步器、并发集合、锁、原子变量和其他工具类别,并在其实现中利用低级线程 API。

执行器将任务提交从任务执行机制中分离出来,由ExecutorExecutorServiceScheduledExecutorService接口描述。同步器有助于常见形式的同步:倒计时锁、循环屏障、交换器、移相器和信号量都是常用的同步器。

并发收集是收集框架的扩展。锁支持高级锁定,并且可以以不同于内置同步和监视器的方式与条件相关联。原子变量封装了单个变量,并支持对该变量进行无锁、线程安全的操作。

Java 7 的新ThreadLocalRandom类描述了一个独立于当前线程的随机数生成器,其新的 Fork/Join 框架允许您递归地将一个任务分解为子任务,并将结果组合起来,以最大限度地利用多个处理器和/或处理器内核。

新的Objects类由操作对象的类方法组成。这些实用工具包括用于比较两个对象、计算对象的哈希代码、要求引用不能为空以及返回对象的字符串的空安全或空容忍方法。

Math类的random()方法是根据Random类实现的,其实例被称为随机数生成器。Random从一个特殊的 48 位种子开始生成一个随机数序列。该值随后通过称为线性同余发生器的数学算法进行修改。

本章及其前几章中的例子利用了底层平台的标准 I/O 工具来创建基于字符的用户界面。然而,Java 也允许您创建 GUI 来实现更引人注目的用户界面。第七章向你介绍 Java 用于创建和丰富图形用户界面的 API。

七、创建和丰富图形用户界面

前几章中介绍的应用具有基于标准 I/O 的用户界面。尽管这些简单的面向字符的用户界面对于演示 Java 特性或与小型实用应用(如第三章的StubFinder应用)进行交互很方便,但对于更复杂的需求,如填写表格或查看 HTML 页面,它们是不够的。然而,Java 也提供了 API,让您创建和丰富更复杂的图形用户界面(GUI)。

抽象窗口工具包(AWT)是 Java 最初的面向 GUI 的 API。在将 AWT 引入 Java 之后,Sun Microsystems 引入了 Java 基础类(JFC ),作为具有许多新功能的 AWT 超集。JFC 的主要 API 有 Swing(用于创建更复杂的 GUI)、Accessibility(用于支持辅助技术)、Java 2D(用于创建高质量的图形)和 Drag and Drop(用于拖放 AWT/Swing GUI 组件,如按钮或文本字段)。

第七章通过向您介绍 AWT、Swing 和 Java 2D,继续探索标准类库。附录 C 向您介绍了可访问性和拖放。

抽象窗口工具包

抽象窗口工具包(AWT) 是 Java 独创的独立于窗口系统的 API,用于创建基于组件、容器、布局管理器和事件的 GUI。AWT 还支持图形、颜色、字体、图像、数据传输等等。

标准类库将 AWT 的许多类型组织成java.awt包和子包。然而,并不是所有的java.awt类型和子包都属于 AWT。比如java.awt.Graphics2Djava.awt.geom都属于爪哇 2D。这种安排之所以存在,是因为基于java.awt的包结构为各种非 AWT 类型提供了天然的适应性。(如今,AWT 通常被视为 JFC 的一部分。)

本节首先介绍工具包,向您介绍 AWT。然后探讨组件、容器、布局管理器和事件。在探讨了图形、颜色和字体之后,本节将重点放在图像上。最后讨论 AWT 对数据传输的支持。

AWT 历史记录

在 JDK 1.0 发布之前(1996 年 1 月 23 日),Sun Microsystems 的开发人员负责将当时的各种窗口系统及其附带的小部件 (GUI 控件,如按钮——Java 将 GUI 控件称为组件)抽象成 Java 应用可以针对的可移植窗口系统。AWT 诞生了,并被包含在 JDK 1.0 中。(传说[见 [www.cs.jhu.edu/~scott/oos/java/doc/TIJ3/html/TIJ316.htm](http://www.cs.jhu.edu/~scott/oos/java/doc/tij3/html/tij316.htm),例如]第一个 AWT 版本必须在一个月内设计并实现。)

JDK 1.0.1 和 1.0.2 版本纠正了各种 AWT 错误,JDK 1.1 版本提供了改进的事件处理模型,极大地简化了应用对 GUI 事件(如按钮点击和按键)的响应。随后的 JDK 版本带来了额外的改进。例如,JDK 1.2 引入了 JFC,JDK 6 引入了桌面、闪屏和系统托盘 API,JDK 7 对 JDK 6 更新 10 (build 12)中首次引入的半透明和异形窗口的支持进行了标准化。

附录 C 涵盖了桌面、闪屏、系统托盘和半透明/异形窗口。

工具包

AWT 使用工具包对窗口系统进行抽象。一个工具包是 AWT 的抽象java.awt.Toolkit类的具体实现。AWT 为 Windows、Solaris、Linux 和 Mac OS 平台使用的每个窗口系统提供了单独的工具包。

Toolkit声明 AWT 调用的各种方法,以获取关于平台窗口系统的信息,并执行各种特定于窗口系统的任务。例如,void beep()会发出音频蜂鸣声。

大多数应用不应该直接调用Toolkit的任何方法;它们是供 AWT 使用的。但是,您可能偶尔会发现调用这些方法会有所帮助。

例如,您可能希望应用在长时间运行的任务完成时发出一声或多声哔哔声,以提醒可能没有看屏幕的用户。您可以通过指定类似如下的代码来完成此任务:

Toolkit toolkit = Toolkit.getDefaultToolkit();
for (int i = 0 ; i < 5; i++)
{
   toolkit.beep();
   try { Thread.sleep(200); } catch (InterruptedException ie) {}
}

这个例子揭示了在调用Toolkit方法之前必须获得一个Toolkit实例,并且通过调用ToolkitToolkit getDefaultToolkit()类方法来实现。它还揭示了您可能希望在连续的哔哔声之间放置一个小的延迟,以确保每个哔哔声都是不同的。

组件、容器、布局管理器和事件

AWT 允许您创建基于组件、容器、布局管理器和事件的 GUI。

一个组件是一个出现在屏幕窗口中的图形小部件;标签、按钮或文本字段就是一个例子。一个窗口由一个称为容器的特殊组件表示。

布局管理器是一个在容器中组织组件和容器的对象。它用于创建有用的 GUI(例如,由标签、文本字段和按钮组成的表单)。

事件是描述按钮点击或其他 GUI 交互的对象。应用向组件注册事件监听器对象来监听特定事件,以便应用代码能够响应它们。

组件概述

AWT 在java.awt包中提供了各种各样的组件类。图 7-1 展示了 AWT 的非菜单组件类的层次结构。

images

图 7-1。 AWT 的非菜单组件类层次植根于java.awt.Component

AWT 的抽象Component类是所有 AWT 非菜单组件(和 Swing 组件)的根类。在Component的正下方是ButtonCanvasCheckboxChoiceContainerLabelListScrollbarTextComponent:

  • Button描述一个可点击的标签。
  • Canvas描述一个空白的矩形区域。您可以子类化Canvas来引入您自己的 AWT 组件。
  • Checkbox描述对/错的选择。你可以使用Checkboxjava.awt.CheckboxGroup来创建一组互斥的单选按钮。
  • Choice描述字符串的下拉列表(也称为弹出菜单)。
  • Container描述存储其他组件的组件。这种嵌套功能让您可以创建任意复杂的 GUI,而且非常强大。(能够将容器表示为组件是复合设计模式的一个例子,这在 Erich Gamma,Richard Helm,Ralph Johnson 和 John Vlissides 的设计模式:可重用面向对象软件的元素的第 163 页中有介绍【Addison-Wesley,1995;ISBN: 0201633612】。)
  • Label描述单行静态文本,为用户提供视觉帮助。
  • List描述字符串的非下拉列表。
  • Scrollbar描述一个数值范围。
  • TextComponent描述任何输入文本的组件。它的TextArea子类描述了用于输入多行文本的文本组件,而它的TextField子类描述了用于输入单行文本的文本组件。

图 7-2 展示了菜单组件类的层次结构。

images

图 7-2。 AWT 的菜单组件类层次植根于java.awt.MenuComponent

AWT 的抽象MenuComponent类(没有扩展Component)是所有 AWT 菜单组件的根类。在MenuComponent的正下方是MenuBarMenuItem:

  • 封装了一个绑定到框架窗口的菜单栏的窗口系统概念。它包含一系列的Menu组件,其中每个Menu组件包含一系列的MenuItem组件。
  • 描述单个菜单项。它的CheckboxMenuItem子类描述了一个通过复选框实现的 menuitem。它的Menu子类描述了一个从菜单栏部署的下拉菜单组件。(Menu扩展了MenuItem来创建任意复杂的菜单。)MenuPopupMenu子类化,描述一个组件内指定位置可以动态弹出的菜单。

Component声明了许多面向组件的非菜单方法。例如,Component声明以下方法来通知调用者组件的可显示、可见和显示状态:

  • 当一个组件处于可显示状态时,boolean isDisplayable()返回 true(该组件连接到一个本机屏幕资源【不久定义】,通常是通过添加到一个容器中)。
  • 当组件处于可见状态boolean isVisible()返回 true(组件出现在屏幕上)。伴随的void setVisible(boolean b)方法让你显示(btrue)或者隐藏(bfalse)一个组件。
  • 当一个组件处于显示状态boolean isShowing()返回 true(该组件是可见的,并且包含在一个也是可见和显示的容器中)。此方法对于确定一个组件是否被另一个组件遮挡很有用。它在被遮挡时返回 false,而isVisible()将继续返回 true。

MenuComponent的曲目方法要短得多。但是,它与Component有一些共同之处。例如,两个类都声明了指定组件字体的方法。

有些ComponentMenuComponent的方法已经被否决,不应该使用。比如Component申报java.awt.peer.ComponentPeer getPeer()MenuComponent申报java.awt.peer.MenuComponentPeer getPeer()。这两种不推荐使用的方法都暗示了 AWT 是如何实现其预定义组件的。

AWT 利用平台的窗口系统来创建各种组件。当您将组件添加到容器中时,AWT 会创建一个对等对象,该对象的类实现了一个ComponentPeerMenuComponentPeer子接口。例如,当您向容器添加一个Button组件类实例时,AWT 会创建一个java.awt.peer.ButtonPeer实例。

images 注意每个 AWT 工具包实现都包含自己的一组对等接口实现。

在后台,组件对象与对等对象通信,对等对象与 JDK 库中的本机代码通信。该代码与平台的窗口系统通信,该系统管理出现在屏幕上的本地屏幕资源(一个本地窗口)。

例如,当您向容器中添加一个Button实例时,AWT 调用Componentvoid addNotify()方法,该方法获取当前的工具包并调用其ButtonPeer createButton(Button target)方法来创建这个工具包的Button对等体。

最终,窗口系统被要求创建一个按钮本地屏幕资源。例如,在 32 位 Windows 操作系统上,可以通过调用CreateWindow()CreateWindowEx() Win32 API 函数来获得本机屏幕资源。

除了那些从直接扩展ComponentContainer的非预定义类创建的 AWT 组件之外,其他 AWT 组件都被称为重量级组件,因为它们有相应的对等接口和本机屏幕资源。从自定义ComponentContainer子类创建的组件被称为轻量级组件,因为它们没有对等接口和原生屏幕资源(它们重用其最近祖先的对等接口,这就是 Swing 的工作方式)。你可以调用Componentboolean isLightweight()方法来确定一个组件是否是轻量级的。

images 注意重量级和轻量级组件可以混合在一个组件层次结构中,前提是整个层次结构有效(非容器组件的大小正确;容器组件布局了它们所包含的组件)。当层次结构被无效(例如,在改变组件边界(相对于组件的父容器的宽度、高度和位置)之后,例如当改变按钮的文本时,或者在容器中添加/移除组件之后),AWT 通过调用层次结构的最顶层无效容器上的Containervoid validate()方法来验证

当您浏览各种组件类的 JDK 文档时,您会发现许多有用的构造函数和方法。例如,Button声明了一个Button(String label)构造函数,用于将一个按钮初始化为指定的label文本。或者,您可以调用Button()构造函数来创建一个没有标签的Button。无论使用哪种构造函数,您都可以调用Buttonvoid setLabel(String label)String getLabel()方法来指定和检索显示在按钮上的标签文本。(更改按钮的显示文本会使按钮无效;然后,AWT 执行验证,这将导致组件层次结构被重新布局。)

组件很容易创建,如下面的示例所示,该示例创建了一个“是”按钮:

Button btnYes = new Button("Yes");

images 注意我喜欢给一个组件变量加前缀来表示它的种类。例如,我给按钮加上前缀btn

集装箱概述

按钮、标签、文本字段和其他组件不能直接放在屏幕上。它们需要放在直接放在屏幕上的容器窗口中。

AWT 在java.awt包中提供了几个容器类。图 7-3 展示了他们的层级。

images

图 7-3。 AWT 的容器类层次结构根植于Container

AWT 的Container类是所有 AWT 容器的根类。在Container的正下方是PanelScrollPaneWindow:

  • Panel是最简单的容器。它提供了应用可以附加任何其他组件(包括其他面板)的空间。
  • ScrollPane实现单个(包含)组件的自动水平和/或垂直滚动。包含组件的容器被称为该组件的
  • Window是没有边框的顶层窗口。它的Dialog子类描述了一个对话框(一个请求用户输入的窗口),它的Frame子类描述了一个框架窗口(一个带有边框的顶级窗口,包括一个标题栏)。DialogFileDialog子类描述了一个选择文件的对话框。

声明了许多面向容器的方法。例如,Component add(Component comp)将组件comp附加到容器中,Component[] getComponents()返回容器组件的数组,int getComponentCount()返回容器中组件的数量。

Window声明了一个void pack()方法,用于创建一个足够大的顶级窗口,以它们的首选(自然)大小显示其所有组件。另外,pack()使窗口(以及窗口的任何所有者——对话框通常为其他窗口所有)在不可显示时也可显示。

Window还声明了一个void setSize(int width, int height)方法,让你将窗口调整到一个特定的大小(以像素为单位)。

继续上一个示例,假设您想要将“是”按钮添加到一个面板中(该面板也可能包含“否”按钮)。以下示例向您展示了如何完成此任务:

Panel pnl = new Panel();
pnl.add(btnYes);
布局管理器概述

容器可以包含组件,但不能将它们布置在屏幕上(例如,以行、网格或其他方式)。布局管理器处理这项任务。布局管理器通常与容器相关联,以对容器的组件进行布局。

images 注意布局管理器提供了一种与屏幕大小无关的方式来显示 GUI。如果没有它们,应用将不得不获取当前的屏幕大小,并根据屏幕大小调整容器/组件的大小。这样做可能需要编写数百行代码,这是一个乏味的任务。

AWT 在java.awt包中提供了几个布局管理器:BorderLayout(在一个容器的北、南、东、西、中心区域布局不超过五个组件),CardLayout(将每个包含的组件视为一张卡片;一次只能看到一张卡片,容器充当一叠卡片)、FlowLayout(将组件排列成水平行)、GridBagLayout(垂直、水平或沿组件的基线(用作布局原点的线)布局组件,不要求组件大小相同),以及GridLayout(将组件布局成矩形网格)。

布局管理器类实现了java.awt.LayoutManager接口,该接口声明了当容器的组件需要布局时 AWT 调用的方法。除非您打算创建自己的布局管理器,否则不需要了解这些方法。如果是这样的话,您还需要注意一个子接口java.awt.LayoutManager2,一个LayoutManager

布局管理器通过调用ComponentDimension getPreferredSize()Dimension getMaximumSize()Dimension getMinimumSize()方法来了解组件/容器的首选、最大和最小尺寸。(前面提到的布局管理器类没有考虑最大尺寸,因为这些类是在 JDK 1.0 中引入的,对最大尺寸的支持直到 JDK 1.1 才通过LayoutManager2引入。)

images 注意java.awt.Dimension类声明了包含组件宽度和高度的公共widthheight字段(类型为int)。尽管直接访问这些字段违反了信息隐藏,但这个类的设计者可能认为直接访问这些字段更有效率。此外,Dimension是一个可能永远不会改变的职业。

每个容器都有一个默认的布局管理器。例如,Frame的默认布局经理是BorderLayout,而Panel的默认布局经理是FlowLayout。您可以通过调用Containervoid setLayout(LayoutManager mgr)方法来安装您自己的布局管理器来替换这个默认设置,如下所示:

Panel pnl = new Panel();
pnl.setLayout(new GridLayout(3, 2));

第一行创建一个默认为FlowLayoutPanel。第二行用一个GridLayout替换了这个布局管理器,它在一个三行两列的网格中最多布局了六个组件。

事件概述

用户按键、单击按钮、移动鼠标、选择菜单项以及执行其他 GUI 交互。每个交互被称为一个事件,由抽象java.awt.AWTEvent类的一个具体的java.awt.event子类来描述。

AWTEvent由几个事件类细分:ActionEventAdjustmentEventAncestorEventComponentEventHierarchyEventInputMethodEventInternalFrameEventInvocationEventItemEventTextEvent

ComponentEventContainerEventFocusEventInputEventPaintEventWindowEvent的超类。InputEventKeyEvent的抽象超类,由MenuKeyEvent子类化,MouseEventMenuDragMouseEventMouseWheelEvent子类化。

images 注意并非所有这些事件都被 AWT 使用。例如,MenuDragMouseEvent是特定于 Swing 的。此外,事件可以分为高级或低级。一个高级事件来自与 GUI 的低级交互。例如,动作事件源于按键或鼠标点击。相比之下,面向键盘和面向鼠标的事件都是低级事件

生成事件的组件被称为事件源。当事件发生时,AWTEvent子类实例被创建来描述它们。每个实例都被发送到一个事件队列,随后被分派(发送)到先前向事件源注册的适当的事件监听器。事件侦听器以某种方式响应这些事件,这通常涉及更新 GUI。

通过在组件实例上调用组件类的适当的add*x*Listener()方法,向组件注册事件侦听器,其中 x 被替换为不带Event后缀的事件类名。例如,您可以通过调用Buttonvoid addActionListener(ActionListener al)方法来注册一个带有按钮的动作监听器。

ActionListenerjava.awt.event包中的一个接口。当动作事件发生时,AWT 用ActionEvent对象调用它的void actionPerformed(ActionEvent ae)方法。

以下示例使用先前创建的“是”按钮注册了一个操作侦听器:

btnYes.addActionListener(new ActionListener()                          {                             public void actionPerformed(ActionEvent ae)                             {                                System.out.println("yes was clicked");                             }                          });

当用户点击 Yes 按钮时,AWT 调用actionPerformed(),用一个ActionEvent对象作为这个方法的参数。监听器通过在标准输出设备上输出消息来响应。

Button还声明了一个void removeActionListener(ActionListener al)方法,用于注销先前注册的动作监听器,该监听器标识为al。其他组件类也声明自己的remove*x*Listener(*x*Listener)方法。

ActionListener声明单个方法,但是有些监听器声明多个方法。例如,WindowListener声明了七个方法。因为在需要实现接口的任何地方覆盖每个方法都很繁琐,所以 AWT 还提供了适配器的概念,这是一个方便的类,通过提供每个方法的空版本来实现多方法接口。例如,java.awt.event包包含了一个WindowAdapter类,您将很快看到它的演示。

演示组件、容器、布局管理器和事件

现在,您已经了解了组件、容器、布局管理器和事件(以及事件监听器)的一些基础知识,让我们看看如何将它们组合成一个有用的基于 AWT 的 GUI。我已经创建了一个简单的温度转换应用,它提供了一个 GUI 来获取度数输入,显示度数输出,并触发到摄氏度/华氏度的转换。清单 7-1 展示了源代码。

清单 7-1 。由两个标签、两个文本字段和两个按钮组成的简单 GUI

`import java.awt.Button;
import java.awt.EventQueue;
import java.awt.Frame;
import java.awt.GridLayout;
import java.awt.Label;
import java.awt.Panel;
import java.awt.TextField;
import java.awt.Window;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

class TempVerter extends Frame
{
   TempVerter()
   {
      super("tempverter");
      addWindowListener(new WindowAdapter()
                        {
                           @Override
                           public void windowClosing(WindowEvent we)
                           {
                              System.out.println("window closing");
                              dispose();
                           } @Override
                           public void windowClosed(WindowEvent we)
                           {
                              System.out.println("window closed");
                           }
                        });
      Panel pnlLayout = new Panel();
      pnlLayout.setLayout(new GridLayout(3, 2));
      pnlLayout.add(new Label("degrees"));
      final TextField txtDegrees = new TextField(10);
      pnlLayout.add(txtDegrees);
      pnlLayout.add(new Label("result"));
      final TextField txtResult = new TextField(30);
      pnlLayout.add(txtResult);
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = (value-32.0)5.0/9.0;
                    txtResult.setText("celsius = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };       Button btnConvertToCelsius = new Button("convert to celsius");
      btnConvertToCelsius.addActionListener(al);
      pnlLayout.add(btnConvertToCelsius);
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = value
9.0/5.0+32.0;
                    txtResult.setText("fahrenheit = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };
      Button btnConvertToFahrenheit = new Button("convert to fahrenheit");
      btnConvertToFahrenheit.addActionListener(al);
      pnlLayout.add(btnConvertToFahrenheit);
      add(pnlLayout);
      pack();
      setResizable(false);
      setVisible(true);
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         new TempVerter();
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

在几个导入语句之后,清单 7-1 展示了温度转换应用的TempVerter类,它扩展了Frame类来描述显示 GUI 的框架窗口。

TempVerter声明一个无参数的构造函数来构造 GUI。它的main()方法实例化TempVerter并调用它的 noargument 构造函数来创建 GUI。

main()不直接执行new TempVerter();。这样做会在主线程上构造 GUI。相反,main()将 GUI 的创建委托给一个特殊的 AWT 线程,称为事件调度线程(EDT) 。它通过创建一个java.lang.Runnable实例,其run()方法执行new TempVerter();,并将这个 runnable 传递给java.awt.EventQueue类的void invokeLater(Runnable runnable)类方法,后者在 EDT 上执行 runnable。

为了避免潜在的线程同步问题,将 GUI 创建推迟到 EDT。因为讨论这些问题超出了本章的范围,所以请查看 Java 教程 ( [download.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html](http://download.oracle.com/javase/tutorial/uiswing/concurrency/dispatch.html))和“Swing 线程和事件调度线程”文章([www.javaworld.com/javaworld/jw-08-2007/jw-08-swingthreading.html](http://www.javaworld.com/javaworld/jw-08-2007/jw-08-swingthreading.html))以获得更多信息。(虽然这些资料是在 Swing 环境中讨论这个主题的,但是其他资料也包括 AWT。因此,您应该在 EDT 上创建基于 AWT 和基于 Swing 的 GUI。)

TempVerter()首先通过super(“tempverter”);调用Frame(String title)构造函数,这样TempVerter就会出现在框架窗口的标题栏上。然后,它向框架窗口注册一个窗口侦听器,以便当用户关闭窗口时(例如,通过单击窗口标题栏上的 X 按钮),该窗口将关闭(并且应用将结束)。

监听器是一个WindowAdapter匿名子类的实例,它覆盖了WindowListenervoid windowClosing(WindowEvent we)void windowClosed(WindowEvent we)方法。点击 X 或从窗口的系统菜单中选择关闭会触发对windowClosing()的调用。您通常会重写此方法以保存更改(例如,文本编辑器未保存的编辑)。

为了正确地终止应用,windowClosing()必须调用Windowvoid dispose()方法,该方法释放窗口使用的所有本机屏幕资源,并向应用的事件队列发送一个窗口关闭事件。AWT 随后通过调用 windowClosed()来调度该事件,以表示窗口已经关闭。任何最后的清理都可以用这种方法进行。

images 注意有些人更喜欢调用java.lang.System类的void exit(int status)方法来终止应用。要了解更多信息,请查看 Oracle 在[download.oracle.com/javase/7/docs/api/java/awt/doc-files/AWTThreadIssues.html](http://download.oracle.com/javase/7/docs/api/java/awt/doc-files/AWTThreadIssues.html)的“AWT 线程问题”页面。

接下来,构造函数实例化Panel来包含 GUI 的组件。然后,它为这个容器分配一个三行两列的布局管理器来管理它的组件。

前两行网格中的每一行都显示了LabelTextField实例。标签告诉用户要输入什么,或者表明文本字段正在显示结果。文本字段请求输入或显示输出。传递给每个TextField构造函数的值根据可显示的列来指定 textfield 的宽度,其中被定义为近似的平均字符宽度(并且依赖于平台)。

最后一个网格行显示了一对用于执行转换的Button实例。每个实例都被分配了一个动作监听器,它通过获取顶部 textfield 的文本(通过TextFieldString getText()方法,该方法继承自TextFieldTextComponent超类),将其转换为一个数字,并通过调用TextField的 overriding void setText(String t)方法将其分配给底部 textfield,来响应按钮点击。

填充面板后,构造函数将面板添加到框架窗口中。然后,它调用pack()来确保框架窗口足够大,能够以它们喜欢的尺寸显示它的组件,调用Framevoid setResizable(boolean resizable)方法和一个false参数来防止用户调整框架窗口的大小(并使它看起来难看),调用setVisible()和一个true参数来显示框架及其组件。

构造函数返回到main()后,这个类方法退出。然而,框架窗口仍然在屏幕上,因为它连接到一个本地屏幕资源,并且因为运行的 EDT 是一个非守护线程(在第四章中讨论)。

编译清单 7-1 ( javac TempVerter.java)并运行这个应用(java TempVerter)。图 7-4 显示了在 Windows XP 平台上生成的 GUI。

images

图 7-4。点击 X 按钮关闭该窗口并终止应用。

当您输入非数字文本或将度数文本字段留空时,TempVerter会向标准输出设备输出一条“bad input消息。此外,当您关闭窗口时,该应用在不同的行上输出“window closing”和“window closed”消息。

images 注意按 Tab 键可以前进到下一个组件,按 Shift-Tab 键可以后退到上一个组件。当您跳转到的组件可以获得输入时,它就会获得焦点——唯一能够获得焦点的TempVerter组件是两个文本字段和两个按钮。当您禁用一个输入组件时,通过在组件实例上调用带有false参数的Componentvoid setEnabled(boolean b)方法,它不再具有焦点。

图 7-4 揭示了所有组件具有相同的尺寸,这是由于GridLayout忽略了组件的首选尺寸。最终的 GUI 看起来并不专业,但是我们可以通过一点点努力来改善 GUI 的外观,如清单 7-2 中的所示。

清单 7-2 。改进TempVerter的 GUI

`import java.awt.Button;
import java.awt.EventQueue;
import java.awt.Frame;
import java.awt.GridLayout;
import java.awt.Label;
import java.awt.Panel;
import java.awt.TextField;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

class TempVerter
{
   static Panel createGUI()
   {
      Panel pnlLayout = new Panel();
      pnlLayout.setLayout(new GridLayout(3, 1));
      Panel pnlTemp = new Panel();
      pnlTemp.add(new Label("Degrees")); final TextField txtDegrees = new TextField(10);
      pnlTemp.add(txtDegrees);
      pnlLayout.add(pnlTemp);
      pnlTemp = new Panel();
      pnlTemp.add(new Label("result"));
      final TextField txtResult = new TextField(30);
      pnlTemp.add(txtResult);
      pnlLayout.add(pnlTemp);
      pnlTemp = new Panel();
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = (value-32.0)5.0/9.0;
                    txtResult.setText("celsius = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };
      Button btnConvertToCelsius = new Button("convert to celsius");
      btnConvertToCelsius.addActionListener(al);
      pnlTemp.add(btnConvertToCelsius);
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = value
9.0/5.0+32.0;
                    txtResult.setText("fahrenheit = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };
      Button btnConvertToFahrenheit = new Button("convert to fahrenheit");
      btnConvertToFahrenheit.addActionListener(al);
      pnlTemp.add(btnConvertToFahrenheit);
      pnlLayout.add(pnlTemp);
      return pnlLayout;    }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         final Frame f = new Frame("tempverter");
                         f.addWindowListener(new WindowAdapter()
                         {
                              @Override
                              public void windowClosing(WindowEvent we)
                              {
                                 f.dispose();
                              }
                         });
                         f.add(createGUI());
                         f.pack();
                         f.setResizable(false);
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

清单 7-2 给出了创建 GUI 的另一种架构。不是子类化Frame,而是直接实例化这个类,调用各种方法来配置和显示框架窗口。(创建一个返回包含整个 GUI 的Panel对象的类方法很方便,比如createGUI()。返回的Panel实例被传递给Frameadd()方法来安装 GUI。)

图 7-5 展示了改进后的图形用户界面。

images

图 7-5。一个更好看的 GUI 是通过将组件包装在嵌套面板中实现的。

请注意,组件以其首选大小显示。这是由于添加了一个标签和一个文本字段,或者将这两个按钮添加到一个嵌套面板(其布局管理器是 flow),然后将该面板添加到主布局面板。(流动布局让每个组件呈现其自然[首选]大小。)

虽然图 7-5 的 GUI 看起来比图 7-4 中显示的 GUI 更好,但仍有改进的空间。例如,我们可以将学位和结果标签以及文本字段左对齐。我们还可以确保每个按钮都有相同的大小。图 7-6 向你展示了最终的 GUI 会是什么样子。

images

图 7-6。通过对齐组件和调整组件大小,可以获得更好看的 GUI。

通过对存储标签和文本字段的两个pnlTemp变量中的每一个执行((FlowLayout) pnlTemp.getLayout()).setAlignment(FlowLayout.LEFT);来左对齐标签。这个方法调用获得pnlTemp的默认流布局管理器,并在这个实例上调用FlowLayoutvoid setAlignment(int alignment)方法,以将面板的组件与容器的左边缘对齐(由于FlowLayoutLEFT常量)——FlowLayout在面板的每一侧留下默认的 5 像素间隙作为边距。

但是,文本字段没有左对齐。为了对齐它们,我们需要将较宽的度数标签的首选大小设置为较窄的结果标签的首选大小。类似地,我们需要将转换为摄氏按钮的首选大小设置为转换为华氏按钮的首选大小,以便它们具有相等的宽度。

通过将下面的void fixGUI(Frame)类方法引入到TempVerter类中,可以部分地完成这些任务:

static void fixGUI(Frame f)
{
   Panel pnl = (Panel) f.getComponents()[0]; // 1
   Panel pnlRow = (Panel) pnl.getComponents()[0]; // 2
   Label l1 = (Label) pnlRow.getComponents()[0]; // 3
   pnlRow = (Panel) pnl.getComponents()[1]; // 4
   Label l2 = (Label) pnlRow.getComponents()[0]; // 5
   l1.setPreferredSize(l2.getPreferredSize()); // 6
   pnlRow = (Panel) pnl.getComponents()[2]; // 7
   Button btnToC = (Button) pnlRow.getComponents()[0]; // 8
   Button btnToF = (Button) pnlRow.getComponents()[1]; // 9
   btnToC.setPreferredSize(btnToF.getPreferredSize()); // 10
}

通过对TempVerter框架窗口的引用来调用fixGUI(Frame)(TempVerter.this提供了该引用)。它首先调用f.getComponents()[0]来获取添加到框架窗口的面板。(清单 7-2 将该面板标识为pnlLayout。)

pnl / pnlLayout包含三个Panel实例(回想一下pnlTemp)。第二行获取第一个实例,并将其引用分配给pnlRow。第三行提取度数标签组件,这是该面板中的第一个组件(位置 0)。

第四行获取第二个Panel实例,该实例包含结果标签及其关联的 textfield。第五行提取这个标签。

第六行调用结果标签上的getPreferredSize(),然后用这个首选大小调用Componentvoid setPreferredSize(Dimension preferredSize)方法来收缩学位标签的宽度,这样两个文本字段都是左对齐的。

第七行获取第三个Panel实例,它包含两个按钮,第八和第九行提取这些按钮,第十行将转换为摄氏按钮的首选大小设置为更宽的转换为华氏按钮的大小。

fixGUI(Frame)引入TempVerter只是解决方案的一部分。我们还必须调用这个方法,这样做的合适位置是在框架窗口的pack()setVisible()方法调用之间。

fixGUI()必须在pack()之后调用,因为直到调用pack()之后才知道首选大小。该方法必须在setVisible()之前调用,因为它改变了首选尺寸。setVisible()当这些变化在这个方法被调用之前发生时,可以适应这些变化。但是,当它们在调用setVisible()后产生时,pack()将必须被第二次调用。

images 注意虽然fixGUI()对于琐碎的应用来说很方便,但是在了解了更多关于布局管理的知识之后,你就不需要使用它了(不幸的是这已经超出了本章的范围)。fixGUI()编码可能会很繁琐,无论何时更改 GUI,您都需要修改它。图形、颜色和字体

Component类声明了一个void paint(Graphics g)方法来绘制组件。当一个组件第一次展示时,或者当它被损坏(部分或完全被另一个组件遮挡)并被重新展示时,就会发生喷漆。

传递给该方法的参数描述了一个图形上下文,一个从抽象java.awt.Graphics类的具体子类创建的对象。该对象描述了一个在其上绘制像素的绘图表面(例如,一个监视器屏幕、一个打印机页面或一个图像缓冲区)。

绘图表面有一个二维坐标系,其(0,0)原点在左上角,水平(X)轴从左到右正向增加,垂直(Y)轴从上到下正向增加。图 7-7 所示为该坐标系。

images

图 7-7。绘图表面的坐标系锚定在左上角的原点。

Graphics声明在表面上绘图和设置上下文状态的各种方法。其绘制方法包括以下几种:

  • void drawLine(int x1, int y1, int x2, int y2)用当前颜色从(x1y1)到(x2y2)画一条线。
  • void drawOval(int x, int y, int width, int height)用当前颜色绘制一个椭圆的轮廓,使该椭圆符合左上角在(xy)且范围为(widthheight)的边界框(最小的外接矩形)。椭圆形覆盖的区域宽width+1像素,高height+1像素。
  • void drawRect(int x, int y, int width, int height)用当前颜色绘制一个左上角在(xy)、范围在(widthheight)的矩形轮廓,使右边缘位于x+width,下边缘位于y+height
  • void drawString(String str, int x, int y)用当前颜色和当前字体绘制由str指定的字符。最左边字符的基线在(xy)。
  • void fillOval(int x, int y, int width, int height)用当前颜色绘制一个实心椭圆,使该椭圆适合左上角位于(xy)且范围为(widthheight)的边界框。
  • void fillRect(int x, int y, int width, int height)用当前颜色绘制一个左上角在(xy)、范围在(widthheight)的填充矩形,使右边缘位于x+width-1,下边缘位于y+height-1

状态方法包括以下内容:

  • void setColor(Color c)为传递给cjava.awt.Color实例设置当前颜色。Color声明了几个用于常见颜色的大写/小写Color常量(例如RED / redGREEN / greenBLUE / blue)和用于描述任意颜色的构造函数——通常使用大写颜色常量。一个伴随的Color getColor()方法返回当前颜色。
  • void setFont(Font f)将当前字体设置为传递给fjava.awt.Font实例。一个伴随的Font getFont()方法返回当前字体。

下面的示例演示了各种绘制和状态方法:

public void paint(Graphics g)
{
   g.setColor(Color.RED);
   g.drawLine(10, 10, 20, 20);
   g.setFont(new Font("Arial", Font.BOLD, 10));
   g.drawString("Hello", 35, 35);
}

第一条语句将当前颜色设置为Color.RED,第二条语句用该颜色从起点(1010)到终点(2020)画一条线。(如果在绘制之前没有指定颜色,颜色默认为组件的背景色,这是从ComponentColor getBackground()方法返回的。)

第三条语句调用FontFont(String name, int style, int size)构造函数来创建一个Font对象,该对象描述一个名为Arial的字体,其样式为BOLD,磅值为10磅值是一种印刷度量,大约为 1/72 英寸。(其他支持的样式有PLAINITALICITALIC结合BOLD.)然后这个对象作为当前字体安装。

字体名称可以是字体族名(如 Arial)或字体面名(结合样式信息的字体族名,如 Arial Bold)。当指定字体系列名称时,style参数用于从系列中选择最合适的字体。当一个字体名称被指定时,字体的样式和style参数被合并,以从同一个系列中找到最匹配的字体。例如,当字体名称“Arial Bold”用样式Font.ITALIC指定时,AWT 在“Arial”系列中查找粗体和斜体的字体,并且可以将字体实例与物理字体“Arial Bold Italic”相关联。style参数与指定的脸型合并,不相加或减去。这意味着,指定加粗字体和加粗样式不会使字体加粗,指定加粗字体和普通样式不会使字体变浅。

Java 支持逻辑字体和物理字体。一种逻辑字体是一种保证在所有平台上都受支持的字体;将Font预定义的DIALOGDIALOG_INPUTMONOSPACEDSERIFString常量之一传递给Font()以选择逻辑字体。物理字体是一种非逻辑字体,不一定在所有平台上都受支持。Arial 是广泛支持的物理字体的一个例子——它可能在 Java 运行的所有平台上都可用。

images 注意指定字体名称时要小心,因为并非所有平台上都有所有字体。在这一章的后面,我会告诉你如何识别所有支持的字体名称。

最后,第四条语句以当前颜色和字体绘制Hello,基线在(3535)。

我之前将基线定义为用作布局原点的线。这个术语也被定义为字体上升和下降的分界线,如图图 7-8 所示。

images

图 7-8。字体的上升和下降是相对于基线的。

每种字体都与不同的尺寸相关联。上升是字体字符高于基线的部分;下降是这些字符在基线以下的部分。文本行之间添加的额外空格被称为前导。当上升、下降和前导加在一起时,形成字体的高度。最后,前进粗略指定下一个字符应该出现的基线位置。

AWT 的java.awt.FontMetrics类封装了这些测量信息。您可以通过调用Graphics类的FontMetrics getFontMetrics()方法获得该类的实例,该方法返回当前字体的字体度量。在它的各种方法中,您会发现int stringWidth(String str)方法(它返回当前字体中显示str字符的总前进宽度)对于水平居中字符串很有用。

虽然您可以通过子类化 component 类并覆盖paint()在任何组件(包括容器)上绘图,但是您应该尽量避免这样做,以避免混淆用户或审查您代码的人。相反,您应该利用 AWT 的Canvas类,它就是为此目的而设计的。

要使用Canvas,必须扩展这个类并覆盖paint()。您还需要指定它的首选大小,以便可以在屏幕上查看画布。通过覆盖getPreferredSize()来返回包含画布范围的Dimension对象,或者通过调用包含首选大小的Dimension对象的setPreferredSize()来完成这个任务(如fixGUI()所示)。

我已经创建了一个演示CanvasGeometria应用。(虽然Geometria只是一个呈现基于Canvas的闪屏组件的框架,但它可以变成一个用于基础几何教学的成熟应用。)清单 7-3 摘录了这个应用的SplashCanvas类。

清单 7-3 。创建闪屏

class SplashCanvas extends Canvas {    private Dimension d;    private Font f;    private String title;    private boolean invert; // defaults to false (no invert)    SplashCanvas()    {       d = new Dimension(250, 250);       f = new Font("arial", Font.BOLD, 50);       title = "geometria";       addMouseListener(new MouseAdapter()                        {                           @Override                           public void mouseClicked(MouseEvent me)                           {                              invert = !invert;                              repaint();                           }                        });    }    @Override    public Dimension getPreferredSize()    {       return d;    }    @Override    public void paint(Graphics g)    {       int width = getWidth();       int height = getHeight();       g.setColor(invert ? Color.BLACK : Color.WHITE);       g.fillRect(0, 0, width, height);       g.setColor(invert ? Color.WHITE : Color.BLACK);       for (int y = 0; y < height; y += 5)          for (int x = 0; x < width; x += 5)             g.drawLine(x, y, width-x, height-y);       g.setColor(Color.YELLOW);       g.setFont(f);       FontMetrics fm = g.getFontMetrics();       int strwid = fm.stringWidth(title);       g.drawString(title, (width-strwid)/2, height/2);       g.setColor(Color.RED);       strwid = fm.stringWidth(title);       g.drawString(title, (width-strwid)/2+3, height/2+3);       g.setColor(Color.GREEN);       g.fillOval(10, 10, 50, 50);       g.setColor(Color.BLUE);       g.fillRect(width-60, height-60, 50, 50);    } }

清单 7-3 的SplashCanvas类模拟了一个闪屏,一个出现在 GUI 出现之前的窗口。当应用初始化时,闪屏经常呈现给用户以吸引他们的注意力。(我将在附录 c 中详细介绍闪屏。)

有几个有趣的地方:

  • 我预先创建了DimensionFontString对象,以避免不必要的对象创建。
  • 我声明了一个名为invert的布尔变量,它(当 true 时)导致 splash 画布的背景部分被反转。
  • 我声明了一个向画布注册鼠标监听器的构造函数。当鼠标光标位于该组件上时,只要用户单击鼠标按钮,鼠标监听器的void mouseClicked(MouseEvent me)方法就会被调用。这个方法切换invert并调用Componentvoid repaint()方法,这告诉 AWT 尽快调用paint()
  • 我调用Componentint getWidth()int getHeight()方法来获取画布的宽度和高度(以像素为单位)。
  • 我调用fillRect()使用当前颜色(黑色或白色)绘制画布的所有像素。
  • 我使用一对嵌套循环来画线。您应该避免在paint()方法中使用冗长的循环,因为它们会降低用户界面的性能。较短的回路不是问题。
  • 我通过从画布的宽度中减去总前进宽度(从stringWidth()返回)并将结果除以 2 来水平居中底部的字符串。我将画布的高度除以 2,使字符串的基线垂直居中。
  • 我首先用黄色(阴影颜色)绘制底部的字符串,然后用红色绘制相同的字符串,但是水平偏移三个像素,垂直偏移三个像素,从而实现投影效果。

图 7-9 显示了未反转的画布,黄色文本上有红色,一个绿色椭圆形和一个蓝色矩形。

images

图 7-9。画布可以用来绘制应用的闪屏。

关于绘画,我还有更多的话要说,但由于篇幅有限,我无法这样做。例如,Component还声明了一个用于更新重量级组件的void update(Graphics g)方法,以响应一个repaint()方法调用。你可以通过阅读“在 AWT 和 Swing 中绘画”([java.sun.com/products/jfc/tsc/articles/painting/index.html](http://java.sun.com/products/jfc/tsc/articles/painting/index.html))和浏览ComponentContainer类的 JDK 文档来了解这种方法和更多内容。

图片

AWT 通过java.awt.ImageToolkit和其他类支持 GIF、JPEG 和 PNG 图像。因为 Java 2D 很大程度上消除了使用这些类的需要,所以我不会详细讨论 AWT 对图像的支持。但是,您应该对这种支持有所了解,因为各种 JFC 类(如javax.swing.ImageIcon)都与Image一起工作,甚至提供了接受Image参数并(就方法而言)返回Image实例的构造函数和/或方法。

Toolkit类声明了几个createImage()方法,用于创建和返回来自不同来源的Image对象。例如,Image createImage(String filename)返回一个Image对象,该对象代表由filename标识的文件中定义的图像。

Toolkit还声明了创建和返回Image对象的两个getImage()方法。与它们的createImage()对应物不同,getImage()方法缓存Image对象,并且可以将相同的对象返回给不同的调用者。这种共享机制有助于 AWT 节省堆空间,尤其是在加载大型图像时。相比之下,createImage()方法总是返回不在调用者之间共享的新的Image对象。

Image对象代表图像,但不包含图像:一个加载的图像与一个Image对象相关联。这种二分法的存在是因为 Java 最初主要用于 web 浏览器环境。

当时,计算机和网络连接比现在慢得多,通过网络加载大型图像是一个非常耗时的过程。与其强迫一个 applet (一个基于浏览器的应用)一直等到图像完全加载完毕(并惹恼用户),不如决定加载图像的方法通过后台线程异步加载图像,同时将用户的注意力吸引到别处。

当您调用一个createImage()getImage()方法时,一个后台线程开始加载图像,createImage() / getImage()立即返回一个Image对象。

因为图像可能在方法返回后的某个时间才被完全加载,所以您不能立即获得图像的宽度和高度,甚至不能绘制整个图像。因此,Java 提供了java.awt.image.ImageObserver接口来提供当前的图像加载状态。

imagesinfoflags由各种ImageObserver常量(如SOMEBITSERROR)组成,这些常量通过按位异或运算符组合在一起。其他参数取决于infoflags。例如,当infoflags设置为SOMEBITS时,它们为新加载的像素定义一个边界框。

各种ImageGraphics方法用ImageObserver参数声明。例如,Imageint getWidth(ImageObserver observer)int getHeight(ImageObserver observer)方法是通过一个图像观察器调用的,该观察器帮助这些方法确定图像已经加载到可以返回其宽度或高度的程度,或者宽度/高度仍然不可用,在这种情况下,它们返回-1。

类似地,调用Graphics类的boolean drawImage(Image img, int x, int y, ImageObserver observer)方法时会使用一个图像观察器,帮助它确定要绘制图像的哪一部分——图像的左上角位于(xy)。当图像没有完全加载时,图像观察器调用Componentrepaint()方法之一,重新调用paint(),以便后续调用drawImage()来绘制新加载的像素。

images 注意你不需要实现ImageObserver(除非有特殊原因)因为Component已经代表你实现了这个接口。

我已经创建了一个ImageViewer应用,向您展示如何加载和显示图像。这个应用由ImageViewerImageCanvas类组成,清单 7-4 给出了ImageViewer

清单 7-4 。通用图像浏览器

import java.awt.Dimension; import java.awt.EventQueue; import java.awt.FileDialog; import java.awt.Frame; import java.awt.Menu; import java.awt.MenuBar; import java.awt.MenuItem; import java.awt.Panel; import java.awt.ScrollPane; import java.awt.Toolkit; `import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;

class ImageViewer
{
   static ImageCanvas ic;
   static ScrollPane sp;
   static Toolkit tk = Toolkit.getDefaultToolkit();
   static ImageCanvas createGUI(final Frame f)
   {
      MenuBar mb = new MenuBar();
      Menu mFile = new Menu("File");
      MenuItem miOpen = new MenuItem("Open...");
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 FileDialog fd = new FileDialog(f, "Open file");
                 fd.setVisible(true);
                 String curFile = fd.getFile();
                 if (curFile != null)
                 {
                    ic.setImage(tk.getImage(fd.getDirectory()+curFile));
                    sp.doLayout();
                 }
              }
           };
      miOpen.addActionListener(al);
      mFile.add(miOpen);
      MenuItem miExit = new MenuItem("Exit");
      miExit.addActionListener(new ActionListener()
                               {
                                  @Override
                                  public void actionPerformed(ActionEvent ae)
                                  {
                                     f.dispose();
                                  }
                               });
      mFile.add(miExit);
      mb.add(mFile);
      f.setMenuBar(mb);
      return new ImageCanvas();
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()                       {
                         final Frame f = new Frame("ImageViewer");
                         WindowAdapter wa;
                         wa = new WindowAdapter()
                              {
                                 @Override
                                 public void windowClosing(WindowEvent we)
                                 {
                                    f.dispose();
                                 }
                              };
                         f.addWindowListener(wa);
                         sp = new ScrollPane();
                         sp.setPreferredSize(new Dimension(300, 300));
                         sp.add(ic = createGUI(f));
                         f.add(sp);
                         f.pack();
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

ImageViewer声明一个ImageCanvas类字段,该字段引用用于显示图像的图像画布。它还声明了一个ScrollPane类字段,其 scrollpane 包含图像画布,这样您就可以水平和垂直地滚动那些太大而无法在当前屏幕分辨率下完整显示的图像,还声明了一个Toolkit实例,其getImage()方法用于启动用户选择的图像的图像加载过程。

ImageCanvas createGUI(final Frame f)方法创建一个 GUI,由一个菜单栏和一个文件菜单和一个图像画布组成。文件由打开和退出菜单项组成。

当用户选择 Open… ( ...表示将显示一个对话框)时,Open 的动作监听器被调用。这个监听器首先实例化FileDialog并显示它;用户会看到一个特定于平台的对话框,用于选择文件。

当用户关闭该对话框时,调用FileDialogString curFile(方法返回所选文件的名称;当没有选择文件时,此方法返回 null。

如果没有返回 null,那么调用FileDialogString getDirectory()方法返回目录名,这个目录名被加在文件名的前面,这样就可以定位所选的文件。产生的路径名被传递给ToolkitgetImage()方法,返回的Image实例被传递给ImageCanvassetImage()方法来加载和显示图像。ScrollPanevoid doLayout()方法通过将它的子容器(图像画布)调整到它的首选大小来布局这个容器。

当用户选择 Exit 时,将调用 Exit 的操作监听器。它调用框架窗口上的dispose()来释放这个窗口(以及包含的组件)的本地屏幕资源。此外,窗口关闭事件被触发,框架窗口的窗口监听器的windowClosing()方法被调用。

main()方法在 EDT 上创建 GUI。它实例化一个 scrollpane,并将其首选大小设置为一个任意值,作为框架窗口的默认大小(在一个pack()方法调用之后)。

createGUI()方法调用在其Frame参数上安装 menubar,并返回图像画布,该图像画布保存在ImageCanvas类字段中,以便可以从打开的 menuitem 侦听器中访问。图像画布也被添加到 scrollpane,scrollpane 被添加到框架窗口。

清单 7-5 呈现ImageCanvas

清单 7-5 。显示用户选择的图像

import java.awt.Canvas;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Image;
import java.awt.MediaTracker;

class ImageCanvas extends Canvas
{
   private Image image;
   @Override
   public void paint(Graphics g)
   {
      // drawImage() does nothing when image contains the null reference.
      g.drawImage(image, 0, 0, null);
   }
   void setImage(Image image)
   {
      MediaTracker mt = new MediaTracker(this);
      mt.addImage(image, 1);
      try
      {
         mt.waitForID(1);
      }
      catch (InterruptedException ie)
      {
         assert false;
      }
      setPreferredSize(new Dimension(image.getWidth(null),
                                     image.getHeight(null)));
      this.image = image;
   }
}

ImageCanvas声明一个Image字段,该字段存储对要显示的图像的引用。它还覆盖了paint()方法来调用drawImage()。当Image参数为空引用时,这个方法什么也不做;这是在用户选择图像之前调用paint()的情况。null作为ImageObserver参数传递,因为此时图像已经完全加载,您会发现这一点。

调用setImage()方法来加载图像,设置它的首选大小来影响清单 7-4 的sp.doLayout();方法调用,并保存Image字段中的Image参数,以便可以从后续的paint()调用中引用它,这是响应doLayout()而发生的。

图像加载是通过使用java.awt.MediaTracker类完成的。MediaTracker声明了一个void addImage(Image image, int id)方法,该方法将一个Image对象添加到被跟踪的Image对象列表中。关联的id值稍后被MediaTrackervoid waitForID(int id)方法用来开始加载已识别的Image对象,并等待直到所有这些图像都已完成加载。

waitForID()返回后,图像被完全加载,其宽度和高度可用。该信息在随后的getWidth()getHeight()通话中获得。虽然这些调用需要一个图像观察器,这可以通过将this作为一个参数来指定(因为Component实现了ImageObserver),但是这样做是不必要的,因为图像已经被加载了。

宽度和高度随后用于构造一个传递给setPreferredSize()Dimension对象。这个首选大小将被sp.doLayout();考虑在内,它在调用ImageCanvassetImage()方法之后执行——参见清单 7-4 。

图 7-10 展示了ImageViewer的图形用户界面,其中有一幅已加载的图像。

images

图 7-10。玫瑰换个名字也会一样香,这是真的吗?

images 注意 AWT 也支持图像处理。例如,您可以将彩色图像灰度化、模糊图像等。因为 Java 2D 简化了图像处理,并且因为我将在本章后面向您介绍 Java 2D 的图像处理支持,所以我不讨论基于 AWT 的图像处理。

数据传输

基于 GUI 的应用经常需要在它们之间或内部传输数据。例如,文本编辑器的用户可能想要将选定的文本剪切到系统剪贴板,然后将剪贴板的文本粘贴到正在编辑的文档中的另一个位置。

AWT 支持通过系统剪贴板在应用间传输任意对象,通过私有剪贴板在应用内传输对象。该支架由java.awt.datatransfer包及其ClipboardOwnerFlavorListenerFlavorMapFlavorTableTransferable接口组成;以及ClipboardDataFlavorFlavorEventStringSelectionSystemFlavorMapMimeTypeParseExceptionUnsupportedFlavorException类。

Clipboard提供一种通过剪切/复制/粘贴操作将数据传输到剪贴板的机制。您可以通过调用ToolkitClipboard getSystemClipboard()方法获得一个 singleton (单实例)Clipboard对象,该对象提供对平台窗口系统提供的本地剪贴板工具的访问;例如,Clipboard clipboard = Toolkit.getDefaultToolkit.getSystemClipboard();。或者,您可以通过实例化Clipboard来获得私有剪贴板。

Clipboard声明一个void setContents(Transferable contents, ClipboardOwner owner)方法,将剪贴板的当前内容设置为指定的可转移对象,并将指定的剪贴板所有者注册为新内容的所有者。当剪贴板当前不可用时,这个方法抛出java.lang.IllegalStateException

传递给contents的可转移对象是根据以下三种方法从实现Transferable接口的类中创建的:

  • Object getTransferData(DataFlavor flavor)返回包含正在传输的数据的对象。DataFlavor参数通过封装数据的多用途互联网邮件扩展(MIME)类型— [en.wikipedia.org/wiki/MIME](http://en.wikipedia.org/wiki/mime)[en.wikipedia.org/wiki/Internet_media_type](http://en.wikipedia.org/wiki/Internet_media_type)讨论 MIME——以及描述这种数据格式的人类可表达的名称,来识别这种数据的风格(格式)(例如,字符串或 JPEG 图像)。当数据在所请求的风格中不再可用时,该方法抛出java.io.IOException,当所请求的数据风格不受支持时,抛出UnsupportedFlavorException
  • DataFlavor[] getTransferDataFlavors()返回一个由DataFlavor对象组成的数组,这些对象表示这个可转移对象可以提供的数据类型。
  • boolean isDataFlavorSupported(DataFlavor flavor)表示是否支持指定的口味;支持flavor时返回 true。

每次调用setContents(),传递给owner的对象就是剪贴板内容的所有者。如果你用不同的所有者调用这个方法,AWT 通过调用ClipboardOwnervoid lostOwnership(Clipboard clipboard, Transferable contents)方法通知前一个所有者它不再是所有者了(剪贴板上还有一些其他内容)。

因为用户通常想要复制、剪切和粘贴文本,java.awt.datatransfer提供了StringSelection作为Transferable的实现,而ClipboardOwner ( lostOwnership()为空;当您需要这个通知时,您必须子类化StringSelection并覆盖lostOwnership()。您可以使用StringSelection在剪贴板上来回传递字符串。

下面的例子展示了copy()cut()paste()方法,向您展示了如何在TextArea类的上下文中执行复制、剪切和粘贴操作。该示例指定了一个引用TextArea实例的ta变量和一个引用Clipboard实例的clipboard变量:

void copy() {    StringSelection ss = new StringSelection(ta.getSelectedText());    clipboard.setContents(ss, ss); } void cut() {    copy();    ta.replaceRange("", ta.getSelectionStart(), ta.getSelectionEnd()); } void paste() {    Transferable clipData = clipboard.getContents(this);    if (clipData != null)       try       {          if (clipData.isDataFlavorSupported(DataFlavor.stringFlavor))          {             String text = (String) clipData.getTransferData(DataFlavor.stringFlavor);             ta.replaceRange(text, ta.getSelectionStart(),                             ta.getSelectionEnd());          }       }       catch (UnsupportedFlavorException ufe)       {          ta.setText("flavor not supported");       }       catch (IOException ioe)       {          ta.setText("no data to paste");       } }

copy()的第一个任务是通过调用TextComponentString getSelectedText()方法从文本区中提取选中的文本。然后,它将该文本传递给StringSelection(String data)构造函数,以创建包含该文本的可转移对象。

继续,copy()通过调用clipboard.setContents(ss, ss)将该对象传递到剪贴板。因为StringSelection实现了TransferableClipboardOwner,所以同一个StringSelection对象(ss)作为可转移对象和剪贴板拥有者被传递。

cut()简单多了。这个方法首先调用copy()将选中的文本复制到剪贴板。然后它调用TextAreavoid replaceRange(String str, int start, int end)方法,用空字符串替换所选文本(由从TextComponentint getSelectionStart()int getSelectionEnd()方法返回的整数值分隔)。

paste()是三种方法中最复杂的一种。它首先调用ClipboardTransferable getContents(Object requestor)方法返回一个表示剪贴板当前内容的可转移对象(或者当剪贴板为空时返回 null)。requestor参数目前未被使用;它可能会在java.awt.datatransfer包的未来版本中实现。

如果返回的 transferable 不为空,paste()DataFlavor.stringFlavor作为参数调用这个对象上的isDataFlavorSupported()。当支持请求的风格时,此方法返回 true。换句话说,当剪贴板包含文本时,isDataFlavorSupported()返回 true 例如,当剪贴板包含图像时,它将返回 false。

如果isDataFlavorSupported()返回 true,paste()调用getTransferData()返回字符串,然后用这个内容替换选中的字符串。

TextArea包含通过按 Ctrl-C、Ctrl-X 和 Ctrl-V 组合键来执行复制、剪切和粘贴操作的内置支持。然而,TextArea和它的TextComponent超类都没有提供执行这些任务的方法。因此,当您希望以编程方式执行这些操作时(可能是为了响应用户从编辑菜单中选择复制、剪切或粘贴),您必须提供自己的copy()cut()paste()方法(如前面所示)。

我已经创建了一个CopyCutAndPaste应用,它通过前面的copy()cut()paste()方法演示了对文本区域的复制、剪切和粘贴。查阅这本书的代码文件以获得CopyCutAndPaste的源代码。(本书的介绍给出了获取代码文件的说明。)

摇摆

Swing 是一个独立于窗口系统的 API,用于创建基于组件、容器、布局管理器和事件的 GUI。尽管 Swing 扩展了 AWT(您可以在 Swing GUIs 中使用 AWT layout 管理器和事件),但这个 API 在几个方面与它的前身不同,包括:

  • 基于 AWT 的 GUI 采用了运行它们的窗口系统的外观和感觉(行为),因为它们利用了窗口系统的本机屏幕资源。例如,一个按钮看起来和感觉起来像 Windows 上的 Windows 按钮和 X Window-Motif 上的 Motif 按钮。相比之下,Swing GUI 可以在任何窗口系统上运行,或者(由开发人员决定)采用运行它的窗口系统的外观。
  • 为了独立于窗口系统,AWT 组件采用组件特性的最小公分母。例如,如果一个窗口系统上的按钮可以显示带有文本的图像,而另一个窗口系统上的按钮只能显示文本,那么 AWT 就不能提供用于选择性显示图像的按钮功能。相比之下,Swing 的非容器组件和一些容器完全由 Java 管理,因此它们可以拥有任何必要的特性(例如工具提示);这些功能不受窗口系统的影响。出于同样的原因,Swing 可以提供并非在每个窗口系统上都可用的组件;例如,表格和树。

标准类库将 Swing 的许多类型组织成javax.swing包和各种子包。例如,javax.swing.table子包存储支持 Swing 的表组件的类型。

本节通过展示 Swing 的体系结构和示例 Swing 组件向您介绍 Swing。

扩展架构

通过扩展 AWT,Swing 共享 AWT 的架构。然而,Swing 通过提供一个扩展的架构超越了 AWT 所能提供的。这个架构很大程度上基于新的重量级容器、新的轻量级组件和容器、UI 委托以及可插拔的外观。

新型重量级集装箱

javax.swing包包括JDialogJFrameJWindow容器类,它们扩展了它们的java.awt.Dialogjava.awt.Framejava.awt.Window对应类。这些重量级容器管理它们包含的轻量级组件(比如javax.swing.JButton)和容器(比如javax.swing.JPanel)。

JDialogJFrameJWindow和另外两个 Swing 容器使用窗格(特殊用途容器)来组织它们包含的组件/容器。Swing 支持根、分层、内容和玻璃窗格:

  • 根窗格包含分层窗格和玻璃窗格。它是通过javax.swing.JRootPane类实现的。
  • 分层窗格包含应用的菜单栏和内容窗格。它是通过javax.swing.JLayeredPane类实现的。
  • 内容窗格是一个Container子类实例,存储 GUI 的非菜单内容。
  • 玻璃窗格是覆盖分层窗格的透明Component实例。

图 7-11 展示了一个基于窗格的容器架构。

images

图 7-11。使用窗格构建图形用户界面。

支持窗格的容器类存储单个JRootPane实例。这个实例存储了一个JLayeredPane实例和一个用作玻璃窗格的Component实例。JLayeredPane实例存储了一个javax.swing.JMenuBar实例和一个作为内容窗格的Container子类实例。

下面的示例演示了如何创建带有单个按钮的框架窗口:

JFrame f = new JFrame();
JRootPane rp = f.getRootPane();
Container cp = rp.getContentPane();
cp.add(new JButton("Ok")); // Add the button to the frame's content pane.
f.pack();
f.setVisible(true);

支持窗格的容器类实现了javax.swing.RootPaneContainer接口,该接口为访问根窗格以及设置/获取内容、玻璃和分层窗格提供了方便的方法。例如,RootPaneContainerContainer getContentPane()方法就像你调用了getRootPane().getContentPane()一样。它允许您将前面的示例缩短为以下内容:

JFrame f = new JFrame();
getContentPane().add(new JButton("Ok")); // Add the button to the frame's content pane.
f.pack();
f.setVisible(true);

RootPaneContainer用一个void setContentPane(Container content)方法补充了getContentPane(),当你想用一个新的内容窗格替换当前的内容窗格时,你会发现这个方法很有帮助。下面的例子通过创建一个新面板、填充面板(如注释所述)并使用setContentPane()用这个面板替换现有的内容面板来演示setContentPane():

JFrame f = new JFrame();
JPanel pnl = new JPanel();
// Populate the panel.
f.setContentPane(pnl);

images 提示因为玻璃窗格是最后绘制的,所以可以在 GUI 上绘制。此外,因为事件首先被发送到玻璃窗格,所以您可以使用此窗格来阻止鼠标和其他事件到达 GUI。

JFrame声明了一个void setDefaultCloseOperation(int operation)方法,用于指定当用户选择关闭该窗口时默认发生的操作。传递给operation的参数是以下常量之一(在javax.swing.WindowConstants接口中声明,由JFrameJDialog实现):

  • DO_NOTHING_ON_CLOSE:什么都不做;要求程序处理已注册的WindowListener对象的windowClosing()方法中的操作。这个操作相当于前面讨论的 AWT 中的操作。
  • HIDE_ON_CLOSE:调用任何注册的WindowListener对象后自动隐藏框架窗口。这是默认操作。
  • DISPOSE_ON_CLOSE:调用任何注册的WindowListener对象后,自动隐藏并处理框架窗口。
  • EXIT_ON_CLOSE(也在JFrame中声明):通过System.exit()退出应用。

images EXIT_ON_CLOSE在 Java 1.3 中被引入JFrame类,随后在 Java 1.4 中被添加到WindowsConstants(为了完整性)。

新型轻质部件和容器

Swing 的轻量级组件和容器是由抽象的javax.swing.JComponent类的子类实现的,它扩展了Container。(我之前提到过,从定制的ComponentContainer子类中创建的组件和容器被称为轻量级组件和容器。)它们没有对等体,但是重用它们最接近的重量级祖先的对等体。毕竟,Swing 最终必须确保平台的窗口系统能够显示它们。

JComponent引入了几个新功能,包括工具提示、边框和创建非矩形组件的选项:

  • 一个工具提示是一个小的(通常是矩形的)窗口,出现在一个组件上,带有少量的帮助文本。JComponent声明了一个用于指定组件工具提示文本的void setToolTipText(String text)方法。
  • 一个边框是一个位于 Swing 组件边缘和它的容器边缘之间的对象。JComponent声明了一个void setBorder(Border border)方法,用于将边框设置为border,这是一个实现了javax.swing.border.Border接口的类的实例。javax.swing.BorderFactory类声明了几个返回不同种类的边框的类方法。例如,Border createEtchedBorder(int type)通过实例化javax.swing.border.EtchedBorder类创建一个蚀刻边框。传递给type的参数必须是EtchedBorder.RAISEDEtchedBorder.LOWERED之一。
  • 预定义的 AWT 组件(比如按钮)是矩形的,因为它们的本机屏幕资源是矩形的。当你创建自己的组件(通过子类化JComponent)时,你可以通过将false传递给JComponentvoid setOpaque(boolean isOpaque)方法来使它们不呈矩形,这表明不是每个像素都被绘制(因此背景像素可以显示出来)。将true传递给这个方法表明组件绘制了每个像素。(默认值为 false。)

我将在本章后面演示工具提示和边框。

images 注意 AWT 提供了java.awt.Insets类来指定容器在其边缘留出的空间量。例如,Frame有一个顶部嵌入,对应于框架窗口标题栏的高度。边框扩展了 insets 的概念,允许您选择一个对象来绘制空白区域。边框利用插入。例如,Border声明Insets getBorderInsets(Component c)返回指定容器组件的 insets。

UI 代表

在 20 世纪 70 年代末,施乐 PARC 公司发明了模型-视图-控制器(MVC)架构,作为将应用逻辑与用户界面分离的架构模式,以简化 GUI 的创建。

MVC 由以下实体组成:

  • 模型维护组件的状态,比如按钮的按下信息或者出现在文本字段中的字符。
  • 视图呈现了模型的可视化表示,赋予组件其外观。例如,按钮视图通常会根据按钮模型的按下状态显示按钮是按下还是未按下。
  • 控制器决定一个组件如何(甚至是否)响应来自输入设备(如鼠标和键盘)的输入事件,给予组件的感觉。例如,当用户按下按钮时,控制器通知模型更新其被按下的状态,并通知视图重新绘制按钮。

经验表明,管理集成的视图和控制器比单独处理它们更容易。集成的结果被称为用户界面(UI)委托

Swing 组件是基于模型和 UI 委托的,其中 UI 委托使得组件看起来相同,而不管 GUI 下面是什么窗口系统。模型和 UI 代理是独立的,通过事件进行通信,使得一个 UI 代理可以关联多个模型,一个模型可以关联多个 UI 代理。

Swing 组件由一个名称以 J 开头的主类、一个当前模型和一个当前 UI 委托组成。主类将模型连接到 UI 委托,并用于创建组件。

例如,JButton类描述了一个按钮组件。它与由javax.swing.ButtonModel接口描述的模型相关联。模型通过调用void setModel(ButtonModel model)附加到组件上,而JButton从它的javax.swing.AbstractButton超类继承而来。

JButton与抽象javax.swing.plaf.ButtonUI类描述的 UI 委托相关联,抽象javax.swing.plaf.ButtonUI类扩展了抽象javax.swing.plaf.ComponentUI类。Swing 通过调用void setUI(ButtonUI ui)将 UI 委托附加到组件上,而JButton继承了AbstractButton

可插拔的外观

一个外观和感觉是一组 UI 代理,每个组件有一个 UI 代理。例如,Swing 提供了使 Swing GUI 看起来像 Windows XP GUI 的外观。它还提供了使 GUI 看起来和感觉起来都一样的外观和感觉,而不管底层的窗口系统如何。

Swing 还提供了一种选择特定外观和感觉的机制。因为这种机制用于在 GUI 显示之前(甚至在显示之后)将观感插入 GUI,所以观感也被称为可插入观感(PLAF)

支持以下 PLAFs:

  • Basic 是一个抽象的 PLAF,作为其他 plaf 的基础。它位于javax.swing.plaf.basic包中,它的主类是BasicLookAndFeel
  • 金属是跨平台的 PLAF,也是默认的。它位于javax.swing.plaf.metal包中,它的主类是MetalLookAndFeel
  • Multi 是组合 PLAF 的多路复用 PLAF。它位于javax.swing.plaf.multi包中,它的主类是MultiLookAndFeel。(每个多路复用 UI 委托管理其子 UI 委托。创建 Multi 主要是为了与可访问性 API 一起使用。)
  • Nimbus 是一款经过改进的跨平台 PLAF,它使用基于 Java 2D 的矢量图形绘制 GUI,因此在任何分辨率下看起来都很清晰。光轮位于javax.swing.plaf.nimbus包中;它的主类是NimbusLookAndFeel
  • Synth 是一个基于 XML 文件的可换肤 PLAF。它位于javax.swing.plaf.synth包中,它的主类是SynthLookAndFeel
  • GTK 是一个 PLAF,它实现了面向 X 窗口的 GTK 小部件工具包的外观。它位于com.sun.java.swing.plaf.gtk包中,它的主类是GTKLookAndFeel
  • Motif 是一个 PLAF,它实现了面向 X 窗口的 Motif 小部件工具包的外观。它位于com.sun.java.swing.plaf.motif包中,它的主类是MotifLookAndFeel
  • Windows 是一个 PLAF,实现了当前 Windows 平台(例如,经典 Windows、Windows XP 或 Windows Vista)的外观和感觉。它位于com.sun.java.swing.plaf.windows包中,它的主类是WindowsLookAndFeel

主要的 PLAF 类最终扩展了抽象的javax.swing.LookAndFeel类。此外,出于许可的原因,Swing 只允许您在基于 X 窗口的平台上使用 GTK PLAF,并且只允许您在 Windows 平台上使用 Windows PLAF。

javax.swing.UIManager类提供了在显示 GUI 之前安装外观的void setLookAndFeel(String className)类方法。当无法找到由className命名的LookAndFeel子类时,该方法抛出java.lang.ClassNotFoundException中的一个;当无法反射性地创建类的新实例时,抛出java.lang.InstantiationException;当类或初始化器不可访问时,抛出java.lang.IllegalAccessException;当 PLAF 无法在当前平台上运行时,抛出javax.swing.UnsupportedLookAndFeelException;当className标识一个没有扩展LookAndFeel的类时,抛出java.lang.ClassCastException

以下示例尝试在创建 GUI 之前安装 Nimbus 作为当前外观:

try
{
   UIManager.setLookAndFeel("javax.swing.plaf.nimbus.NimbusLookAndFeel");
   new GUI();
}
catch (Exception e)
{
}

假设您的应用提供了一个菜单,让用户选择 GUI 的外观和感觉。选择 menuitem 后,必须更新可见的 GUI 以反映选择。Swing 允许您从 menuitem 的动作监听器(或从 EDT 上的其他地方)完成这项任务,如下所示:

try
{
   UIManager.setLookAndFeel("javax.swing.plaf.nimbus.NimbusLookAndFeel");
   SwingUtilities.updateComponentTreeUI(frame); frame.pack();
}
catch (Exception e)
{
}

javax.swing.SwingUtilities类声明了一个void updateComponentTreeUI(Component c)类方法,该方法通过调用位于以c为根的组件树中的每个组件的void updateUI()方法来改变外观,该方法通常引用一个框架窗口。updateUI()调用UIManagerComponentUI getUI(JComponent target)方法返回新外观的 UI 委托,并将该委托传递给组件的setUI()方法。例如,JButtonupdateUI()方法实现如下:

public void updateUI()
{
   setUI((ButtonUI) UIManager.getUI(this));
}

frame.pack();将组件的大小调整到他们喜欢的大小,因为在新的外观和感觉下,这些大小可能会改变。

images 注意有关 PLAFs 的更多信息,请查看 Java 教程的“修改外观”一课([download.oracle.com/javase/tutorial/uiswing/lookandfeel/index.html](http://download.oracle.com/javase/tutorial/uiswing/lookandfeel/index.html))。

采样摆动组件

Swing 提供了各种各样的组件,您可以通过运行SwingSet2演示应用来探索这些组件,您可能在安装《JDK 7》时与其他演示程序一起安装了这些组件(有关安装说明,请参见第一章)。如果您没有安装演示,请重新运行 JDK 7 安装程序,并确保它已配置为安装它们。

要运行SwingSet2,切换到 JDK 7 主目录的demo\jfc\SwingSet2目录并执行java -jar SwingSet2.jar。图 7-12 显示这个应用提供了一个 GUI,包括一个菜单、一个工具栏和一个选项卡式工作区,让你在与不同组件演示交互和查看当前演示的源代码之间切换。

images

图 7-12。 SwingSet2允许您在不同的外观和感觉上下文中查看 Swing 组件并与之交互。

SwingSet2开始运行时,它基于默认的 Metal(也称为 Java)外观和感觉呈现它的 GUI。但是,您可以通过从外观&手感菜单中选择来改变另一种外观和手感。例如,图 7-12 展示了SwingSet2的 GUI 在外观和感觉被改成光轮后的样子。

images 不幸的是,出于简洁的需要,我无法在本章中全面介绍 Swing 组件。您将在后续章节和附录 c 中找到其他组件内容。

重访温度转换器

我之前展示了一个演示 AWT 容器、组件、布局管理器和事件的TempVerter应用。清单 7-6 展示了这个应用的一个 Swing 版本,帮助你比较和对比 Swing GUI 代码和它的 AWT 对应物。

清单 7-6 。为 Swing 重构TempVerter

`import java.awt.Container;
import java.awt.EventQueue;
import java.awt.FlowLayout;
import java.awt.GridLayout;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTextField;

import javax.swing.border.Border;
import javax.swing.border.EtchedBorder;

class TempVerter
{
   static JPanel createGUI()
   {
      JPanel pnlLayout = new JPanel();
      pnlLayout.setLayout(new GridLayout(3, 1));
      JPanel pnlTemp = new JPanel(); ((FlowLayout) pnlTemp.getLayout()).setAlignment(FlowLayout.LEFT);
      pnlTemp.add(new JLabel("degrees"));
      final JTextField txtDegrees = new JTextField(10);
      txtDegrees.setToolTipText("enter a numeric value in this field.");
      pnlTemp.add(txtDegrees);
      pnlLayout.add(pnlTemp);
      pnlTemp = new JPanel();
      ((FlowLayout) pnlTemp.getLayout()).setAlignment(FlowLayout.LEFT);
      pnlTemp.add(new JLabel("result"));
      final JTextField txtResult = new JTextField(30);
      txtResult.setToolTipText("don't enter anything in this field.");
      pnlTemp.add(txtResult);
      pnlLayout.add(pnlTemp);
      pnlTemp = new JPanel();
      ImageIcon ii = new ImageIcon("thermometer.gif");
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = (value-32.0)5.0/9.0;
                    txtResult.setText("Celsius = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };
      JButton btnConvertToCelsius = new JButton("Convert to Celsius", ii);
      btnConvertToCelsius.addActionListener(al);
      pnlTemp.add(btnConvertToCelsius);
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    double value = Double.parseDouble(txtDegrees.getText());
                    double result = value
9.0/5.0+32.0;
                    txtResult.setText("Fahrenheit = "+result);
                 }
                 catch (NumberFormatException nfe)
                 {
                    System.err.println("bad input");
                 }
              }
           };
      JButton btnConvertToFahrenheit = new JButton("Convert to Fahrenheit", ii);
      btnConvertToFahrenheit.addActionListener(al);
      pnlTemp.add(btnConvertToFahrenheit);
      Border border = BorderFactory.createEtchedBorder(EtchedBorder.LOWERED);
      pnlTemp.setBorder(border);
      pnlLayout.add(pnlTemp);       return pnlLayout;
   }
   static void fixGUI(Container c)
   {
      JPanel pnlRow = (JPanel) c.getComponents()[0];
      JLabel l1 = (JLabel) pnlRow.getComponents()[0];
      pnlRow = (JPanel) c.getComponents()[1];
      JLabel l2 = (JLabel) pnlRow.getComponents()[0];
      l2.setPreferredSize(l1.getPreferredSize());
      pnlRow = (JPanel) c.getComponents()[2];
      JButton btnToC = (JButton) pnlRow.getComponents()[0];
      JButton btnToF = (JButton) pnlRow.getComponents()[1];
      btnToC.setPreferredSize(btnToF.getPreferredSize());
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         final JFrame f = new JFrame("TempVerter");
                         f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
                         Border b = BorderFactory.createEmptyBorder(5, 5, 5, 5);
                         f.getRootPane().setBorder(b);
                         f.setContentPane(createGUI());
                         fixGUI(f.getContentPane());
                         f.pack();
                         f.setResizable(false);
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

清单 7-6 展示了与清单 7-2 类似的架构。然而,它也展示了各种 Swing 特性,包括 Swing 组件/容器、ImageIcon、工具提示、边框和setDefaultCloseOperation()

因为 Swing 组件类与它们的 AWT 对应物具有相似的 API,所以您通常可以在 AWT 类名前面加上J来引用等价的 Swing 类——不要忘记更改 import 语句。例如,在Label前面加上J,从 AWT 的Label类变成javax.swing.JLabel

ImageIcon被实例化以加载温度计图标图像——在幕后MediaTracker用于确保图像被完全加载。然后,ImageIcon实例被传递给每个JButton实例的构造函数,这样按钮就会显示这个图标及其标签。

工具提示可以方便地显示小的帮助信息,帮助用户与 GUI 交互。清单 7-6 通过在每个txtDegreestxtResult文本字段上调用setToolTipText()来演示这个特性。当用户将鼠标移动到文本字段上时,会出现一个工具提示来显示它的帮助信息。

清单 7-6 在一对按钮周围的面板上附加一个蚀刻的边框,将它们与其他组件区分开来。因为这个边框与框架窗口对接,所以创建了一个空边框,并将其分配给框架的根窗格,以便在这个窗口的边缘留下一些空间。

setDefaultCloseOperation()方法和它的DISPOSE_ON_CLOSE参数通过释放一个窗口(响应用户关闭请求)来减少冗长性,而不必安装一个窗口监听器。

你可能已经注意到,我把void fixGUI(Container c)类方法放在了pack()方法调用之前,而不是放在pack()之后,正如我在清单 7-2 中所讨论的那样。我之前建议将fixGUI()放在pack()之后,因为(在 AWT 中)首选大小直到pack()方法调用之后才可用,而fixGUI()需要访问首选大小。在 Swing 中,在调用pack()之前,首选大小是可用的,而在调用pack()之后更改它们将需要再次调用pack(),以确保 GUI 大小合适。

编译清单 7-6 并运行这个应用。图 7-13 显示了生成的 GUI。

images

图 7-13。当你将鼠标光标移到一个文本字段上时,会出现一个工具提示。

images TempVerter演示了 Swing 的许多组件中的一些,这些组件位于javax.swing包中。你会发现其他有用的组件包括JScrollPane (Swing 版本的ScrollPane)、JTextArea (Swing 版本的TextArea)和JOptionPane(一个使弹出标准对话框变得容易的类,该对话框提示用户输入一个值或通知他们一些事情)。JOptionPane声明showConfirmDialog()showInputDialog()showMessageDialog()showOptionDialog()类方法来询问确认问题(是/否/取消),提示输入,告诉用户已经发生的事情,并将确认与输入和消息显示结合起来。

TempVerter 遇上 JLayer

假设您计划将您的 Swing 应用作为共享软件发布(参见[en.wikipedia.org/wiki/Shareware](http://en.wikipedia.org/wiki/Shareware)),并希望在 GUI 上显示一条半透明的未注册消息,直到用户注册了它们的副本。您可以通过直接使用玻璃面板来完成这个任务,或者您可以使用 Java 7 中新增的javax.swing.JLayer类。

JLayer的 Javadoc 将该类描述为“Swing 组件的通用装饰器,它使您能够实现各种高级绘画效果,并接收在其边界内生成的所有AWTEvent的通知。”JLayer代表你用玻璃窗格工作。

要使用JLayer,首先扩展javax.swing.plaf.LayerUI类,覆盖各种方法来定制绘画和事件处理。接下来,将这个类的一个实例和被修饰的组件一起传递给JLayer(V view, LayerUI<V> ui)构造函数(JLayer的泛型类型是JLayer<V extends Component>LayerUI的通用类型是LayerUI<V extends Component>。)

传递给这个构造函数的第一个参数是你想要修饰的组件,它被称为视图。第二个参数标识了装饰者对象。

以下摘录自清单 7-6 的修订版,向您展示了如何使用JLayerTempVerter的 GUI 中心添加半透明的未注册消息:

public static void main(String[] args)
{
   Runnable r = new Runnable()
                {
                   @Override
                   public void run()
                   {
                      final JFrame f = new JFrame("TempVerter");
                      f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
                      Border b = BorderFactory.createEmptyBorder(5, 5, 5, 5);
                      f.getRootPane().setBorder(b);
                      LayerUI<JPanel> layerUI;
                      layerUI = new LayerUI<JPanel>()
                      {
                         final Color PALE_BLUE = new Color(0.0f, 0.0f,
                                                           1.0f, 0.1f);
                         final Font FONT = new Font("Arial", Font.BOLD, 30);
                         final String MSG = "UNREGISTERED";
                         @Override
                         public void paint(Graphics g, JComponent c)
                         {
                            super.paint(g, c); // Paint the view.
                            g.setColor(PALE_BLUE);
                            g.setFont(FONT);
                            int w = g.getFontMetrics().stringWidth(MSG);
                            int h = g.getFontMetrics().getHeight();
                            g.drawString(MSG, (c.getWidth()-w)/2,
                                         c.getHeight()/2+h/4);
                         }
                      };
                      JLayer<JPanel> layer;
                      layer = new JLayer<JPanel>(createGUI(), layerUI);
                      f.setContentPane(layer);
                      fixGUI(f.getContentPane());
                      f.pack();
                      f.setResizable(false);
                      f.setVisible(true);
                   }
                };
   EventQueue.invokeLater(r);
}

要创建一个装饰器,您至少要覆盖LayerUIvoid paint(Graphics g, JComponent c)方法。传递给c的组件是视图。

第一个绘制步骤是通过super.paint(g, c);方法调用来绘制视图。后续代码中绘制的任何内容都会出现在视图上。

继续,通过setColor()安装淡蓝色。这种颜色是通过Color(0.0f, 0.0f, 1.0f, 0.1f)创建的——前三个参数代表红色、绿色和蓝色的百分比(在0.0f1.0f之间),最后一个参数代表不透明度(从0.0f,透明,到1.0f,不透明)。

然后安装一种字体,以确保正在绘制的消息足够大,可以被看到。

此时,剩下要做的就是获取消息的宽度和高度,并使用这些值来确定第一个消息字符和基线的位置,然后绘制文本。

创建这个装饰器后,它和视图(从createGUI()返回)被传递到一个新的JLayer实例,该实例作为内容窗格安装。

图 7-14 显示了带有居中半透明未注册消息的结果图形用户界面。

images

图 7-14。未注册消息位于框架窗口的边框中心。

摇摆台

AWT 提供了Canvas类,它的paint()方法可以被覆盖以在其表面绘制图形或图像。您可以通过子类化JComponent并覆盖其paint()方法来引入自己的基于 Swing 的 canvas 类,如下所示:

class SwingCanvas extends JComponent
{
   private Dimension d;
   SwingCanvas()
   {
      d = new Dimension(300, 300); // Create object here to avoid unnecessary
                                   // object creation should getPreferredSize()
                                   // be called more than once.
      // perform other initialization (such as registering a mouse listener) here
   }
   @Override
   public Dimension getPreferredSize()
   {
      return d;
   }
   @Override
   public void paint(Graphics g)
   {
     // perform painting here
   }
}

在 Swing 的上下文中重写paint()通常不是一个好主意,因为JComponent重写这个方法是为了将绘制工作委托给三个受保护的方法:paintComponent()、、和paintChildren()。按此顺序调用这些方法,以确保子组件出现在组件的顶部。

一般来说,组件及其子组件不应该在分配给边框的 insets 区域中绘制。

尽管子类可以覆盖这个方法,但是一个只想专门化 UI 委托的paint()方法的子类应该只覆盖paintComponent()

如果您不关心 UI 代理、边框和子元素,前面的SwingCanvas类应该可以满足您的需求。有关更多信息,请查看 Java 教程的“执行自定义绘画”一课([download.oracle.com/javase/tutorial/uiswing/painting/index.html](http://download.oracle.com/javase/tutorial/uiswing/painting/index.html))。

爪哇 2D

Java 2D 是 AWT 扩展的集合,提供高级的二维图形、文本和图像功能。这个 API 提供了一个灵活的框架,通过艺术线条(也称为矢量图形,见[en.wikipedia.org/wiki/Vector_graphics](http://en.wikipedia.org/wiki/Vector_graphics))、文本和图像来开发更丰富的 GUI。

Java 2D 由位于java.awtjava.awt.image包中的各种类型,以及特定于 Java 2D 的java.awt.colorjava.awt.fontjava.awt.geomjava.awt.image.renderablejava.awt.print包来实现。

本节介绍 Java 2D,首先介绍java.awt包的GraphicsEnvironmentGraphicsDeviceGraphicsConfiguration类。然后探讨了Graphics2D类,以及 Java 2D 对形状和缓冲图像的支持。(为了简洁起见,我不探究文本或打印。)

图形环境、图形设备和图形配置

Java 2D 提供了一个GraphicsEnvironment类,应用可以使用它来了解它们的图形环境(例如,可用的字体系列名称和图形设备)并执行专门的任务(例如,注册字体或创建一个Graphics2D实例以绘制到缓冲图像中)。

在使用GraphicsEnvironment之前,您需要获得这个类的一个实例。通过调用GraphicsEnvironmentGraphicsEnvironment getLocalGraphicsEnvironment()类方法返回平台的GraphicsEnvironment实例来完成这个任务,如下所示:

GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment();

GraphicsEnvironment的 Java 文档声明返回的GraphicsEnvironment实例的资源可能是本地的或者位于远程机器上。例如,Linux 平台允许用户使用安全外壳(参见[en.wikipedia.org/wiki/Secure_Shell](http://en.wikipedia.org/wiki/Secure_Shell))在另一台机器上运行 GUI 应用,并在本地机器上查看 GUI。(如果您有兴趣了解更多这方面的内容,请查看“X Over ssh 2-A Tutorial”[www.vanemery.com/Linux/XoverSSH/X-over-SSH2.html](http://www.vanemery.com/Linux/XoverSSH/X-over-SSH2.html)。)

一旦应用有了一个GraphicsEnvironment实例,它就可以调用GraphicsEnvironment的 String[]getAvailableFontFamilyNames()方法来枚举字体系列名称(如 Arial),如清单 7-7 所示。

清单 7-7 。枚举字体系列名称

`import java.awt.EventQueue;
import java.awt.GraphicsEnvironment;

class EnumFontFamilyNames
{    public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         enumerate();
                      }
                   };
      EventQueue.invokeLater(r);
   }
   static void enumerate()
   {
      GraphicsEnvironment ge;
      ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
      String[] ffns = ge.getAvailableFontFamilyNames();
      for (String ffn: ffns)
         System.out.println(ffn);
   }
}`

应用可能需要枚举字体系列名称并将该列表呈现给用户。例如,自定义字体选择器对话框可能会让用户根据字体系列名称、样式和大小的列表来选择字体。

GraphicsEnvironment还声明了一个返回一组GraphicsDevice实例的GraphicsDevice[] getScreenDevices()方法。每个实例描述了应用可用的图像缓冲区、打印机或光栅屏幕(像素屏幕)。(因为图像缓冲区和打印机不是屏幕,将这个方法命名为getGraphicsDevices()会更容易理解。)

假设ge引用了一个GraphicsEnvironment实例,执行下面一行来获得这个数组:

GraphicsDevice[] gd = ge.getScreenDevices();

您可以通过调用GraphicsDeviceint getType()方法并将结果与GraphicsDeviceTYPE_IMAGE_BUFFERTYPE_PRINTERTYPE_RASTER_SCREEN常量之一进行比较,找出特定的GraphicsDevice实例代表哪种图形设备。

images 注意你可以通过调用GraphicsEnvironmentGraphicsDevice getDefaultScreenDevice()方法来访问默认的图形设备。如果只有一个受支持的设备,getDefaultScreenDevice()相当于getScreenDevices()[0]

getScreenDevices()无头平台(不支持键盘、鼠标或显示器的平台)上被调用时抛出java.awt.HeadlessException。例如,该平台可能是服务器群的一部分(参见[en.wikipedia.org/wiki/Server_farm](http://en.wikipedia.org/wiki/Server_farm))。如果您担心这种可能性,您可以让您的应用首先调用GraphicsEnvironmentboolean isHeadless()类方法,当平台是无头的时,它返回 true。

一旦你有了图形设备,你可以通过调用GraphicsDeviceGraphicsConfiguration[] getConfigurations()方法获得所有支持的配置(颜色模型、边界【设备坐标中的原点和范围】等等)。

假设gd引用了一个GraphicsDevice实例,执行下面一行来获得这个数组:

GraphicsConfiguration[] gc = gd.getConfigurations();

有了一个GraphicsConfiguration实例后,可以通过调用ColorModel getColorModel()了解它的颜色模型,通过调用Rectangle getBounds()了解它的边界,等等。

images 注意您可以通过调用GraphicsDeviceGraphicsConfiguration getDefaultConfiguration()方法来访问默认配置。如果只有一个支持的配置,getDefaultConfiguration()相当于getConfigurations()[0]

在获得一组GraphicsConfiguration之后,应用可以确定它是运行在单屏幕环境中还是多屏幕环境中。

多屏环境

一个多屏幕环境由两个或多个独立屏幕组成,两个或多个屏幕,其中一个屏幕是默认屏幕,其他屏幕显示默认屏幕上出现的内容的副本,或者两个或多个屏幕组成一个虚拟桌面,也称为虚拟设备。图 7-15 展示了一个多屏环境。

images

图 7-15。本例中每个屏幕的分辨率都是 1024x768 像素。

当两个或多个屏幕组合成一个虚拟桌面时,Java 2D 会建立一个虚拟坐标系。该坐标系存在于任何屏幕边界之外,用于识别虚拟桌面内的像素坐标。

其中一个屏幕被称为默认屏幕,其左上角位于(0,0)。如果默认屏幕不在屏幕网格的左上角,Java 2D 可能会要求您使用负坐标,如图 7-15 所示。

应用通过调用getConfigurations()返回的每个GraphicsConfiguration上的Rectangle getBounds()来完成这个任务,然后检查原点是否是(0,0)之外的某个值。GraphicsConfigurationgetBounds()方法返回一个java.awt.Rectangle实例,其xywidthheight字段(类型为int)反映了虚拟坐标系。如果任意(xy)原点不是(0,0),则该环境是虚拟设备环境。

我已经创建了一个IsVDE应用来确定它的环境是否是虚拟设备环境。清单 7-8 展示了这个应用的源代码。

清单 7-8 。检测虚拟设备环境

import java.awt.EventQueue;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;

class IsVDE
{
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      public void run()
                      {
                         test();
                      }
                   };
      EventQueue.invokeLater(r);
   }
   static void test()
   {
      GraphicsEnvironment ge;
      ge = GraphicsEnvironment.getLocalGraphicsEnvironment();
      GraphicsDevice[] gds = ge.getScreenDevices();
      for (GraphicsDevice gd: gds)
      {
         GraphicsConfiguration[] gcs = gd.getConfigurations();
         for (GraphicsConfiguration gc: gcs)
         {
            Rectangle rect = gc.getBounds();
            if (rect.x != 0 || rect.y != 0)
            {
               System.out.println("virtual device environment detected");
               return;
            }
         }
         System.out.println("no virtual device environment detected");
      }
   }
}

假设环境是一个虚拟设备环境,你可以通过调用合适的构造函数,比如Frame(GraphicsConfiguration gc),创建引用不同图形设备的Framejavax.swing.JFrameWindowjavax.swing.JWindow容器窗口。

在多屏幕环境中,桌面区域可能跨越多个物理屏幕设备,GraphicsConfiguration对象的边界是相对于虚拟坐标系的。在该坐标系中设置组件的位置时,使用getBounds()获得所需GraphicsConfiguration的边界,并用这些坐标偏移位置,如下例所示:

Frame f = new Frame(gc); // Assume gc is a GraphicsConfiguration instance.
Rectangle bounds = gc.getBounds();
f.setLocation(10+bounds.x, 10+bounds.y);

图形 2D

Java 2D 的抽象Graphics2D类(一个Graphics子类)描述了一个逻辑绘图表面,在其上绘制图形图元 (2D 形状【如矩形和椭圆】、文本和图像)。

逻辑绘图表面与用户空间相关联,用户空间是 2D 笛卡尔(x/y)平面,其像素被称为逻辑像素,并且具有浮点坐标。于是,各种Graphics2D方法接受浮点坐标值;比如void drawString(String str, float x, float y)

在讨论 AWT 图形时,我之前提到过将一个Graphics子类实例传递给组件的paint()方法。在 Java 1.2 之前,情况总是如此。从 Java 1.2 开始,一个Graphics2D子类实例被传递给paint()。您可以将该实例作为一个Graphics实例使用,或者(在将Graphics转换为Graphics2D后)作为一个Graphics2D实例使用。

传递给组件的paint()方法的Graphics2D子类实例用一个物理绘图表面(例如,光栅屏幕或打印机页面)标识一个输出设备(例如,监视器或打印机)。该表面与设备空间相关联,该设备空间是 2D 笛卡尔平面,其像素被称为物理像素,并且具有整数坐标。

通常,输出设备是默认的监视器,或者是与传递给包含组件的FrameJFrameWindowJWindow构造函数的GraphicsConfiguration相关联的监视器。

在某些时候,Graphics2D必须将逻辑像素映射到物理像素。它通过一种仿射变换(一种将直线转化为直线,将平行线转化为平行线的数学变换)来完成这项任务。

默认情况下,Java 2D 指定了一个仿射变换,该变换将用户空间与设备空间对齐,因此您最终会得到如图 7-7 所示的坐标系。此外,它将 72 个用户空间坐标映射到一个物理英寸。(某些缩放可以在幕后执行,以确保这种关系在特定的输出设备上保持不变。)

您通常不需要关心设备空间和这个映射过程。请记住默认的 72 用户空间坐标到一英寸的映射,Java 2D 将确保您的 Java 2D 创建在各种输出设备上以正确的大小出现。

渲染管道

Graphics2D也是一个渲染管道,它将形状、文本和图像渲染(处理)成特定于设备的像素颜色。此渲染管道维护由以下属性组成的内部状态:

  • 绘制:纯色,渐变(两种纯色之间的过渡),或纹理(复制图像)应用于形状内部和形状的轮廓形状。
  • 描边:创建一个形状来指定另一个形状轮廓的对象。生成的轮廓形状,也称为描边轮廓,填充有绘画属性。轮廓形状没有轮廓。
  • Font : Java 2D 通过创建代表文本字符的形状来呈现文本。字体属性选择为这些字符创建的形状。然后填充这些形状。
  • 变换:图元在被描边和填充之前,进行几何变换。它们可以被旋转、平移、(移动)、(缩放)(拉伸),或者以其他方式被操纵。transformation 属性将图形图元从用户空间转换到设备空间;默认转换将 72 个用户空间坐标映射到输出设备上的 1 英寸。
  • 复合规则 : Graphics2D通过使用复合规则将图形的原始颜色与绘图表面的现有颜色相结合,复合规则决定了结合发生的方式。
  • 裁剪形状 : Graphics2D将其渲染操作限制在裁剪形状的内部;此形状之外的像素不受影响。剪裁形状默认为整个绘图图面。
  • 渲染提示 : Graphics2D识别各种渲染提示,可以指定这些提示来控制渲染。例如,您可以指定抗锯齿来移除形状(如线条)和文本周围的锯齿边缘。

图元通过各种Graphics方法(例如drawLine()fillOval())和下面的Graphics2D方法进入该流水线:

  • void fill(Shape s)用当前的颜料填充形状的内部。形状实现了Shape接口。
  • void draw(Shape s)用当前颜料绘制形状的轮廓。
  • drawstring()方法使用当前颜料通过字符形状绘制文本。
  • drawImage()方法绘制图像。

images 注意虽然你可以调用Graphics方法来绘制形状,但是这些方法的局限性在于它们只接受整数坐标。此外,这些形状(基于多边形的形状除外)不可重复使用。关于多边形,它们只能由直线段组成。相比之下,Java 2D 的Shape类(我将在本章后面简要介绍)没有这些限制。

图 7-16 将渲染管道概念化为独立的操作。可以在特定的实现中组合操作。

images

图 7-16。渲染过程显示笔画、字体和绘画属性不适用于图像。

图元通过各种方法调用呈现给渲染管道,这些方法调用决定渲染如何进行:

  • 传递给fill()的形状没有描边。相反,它们首先被转换,最终被绘制(填充)。
  • 传递到draw()的形状首先被描边,结果轮廓形状被转换。
  • 文本字符首先被转换为当前字体指定的形状。这些字符形状然后被转换。
  • 首先变换图像轮廓。

如图图 7-16 所示,Graphics2D只填充形状和绘制图像。绘制轮廓形状和字符形状是形状填充操作的变体。

栅格化跟随在变换步骤之后。光栅化器将基于矢量的形状转换为 alpha (覆盖)值,这些值决定了形状下的每个目标像素被形状覆盖的程度。关于图像,只有图像轮廓被光栅化。光栅化器会考虑任何指定的渲染提示。

栅格化结果通过当前裁剪形状进行裁剪。未被裁剪丢弃的填充形状部分将通过当前绘画着色。图像没有彩色化,因为它们的像素提供了颜色。

最后,Graphics2D根据其当前的合成规则,将彩色像素(源像素)与已有的目标像素合并,形成新的像素。

栅格化和合成

光栅化器创建一个只包含 alpha 值的矩形图像。在这一点上没有颜色。alpha 值的范围从 0(无覆盖)到 255(全覆盖),图像的 alpha 值集合称为其 alpha 通道。(Alpha 值也可以表示为范围从 0.0 到 1.0 的浮点值。)

光栅化器默认选择 255 或 0 的 alpha 值。因为生成的源像素要么完全覆盖现有的目标像素,要么不覆盖该像素,所以线条、文本和其他几何图元将倾向于具有锯齿状边缘。这被称为混叠

当您将抗锯齿指定为渲染提示时,光栅化器会稍微变慢(它有更多工作要做),但会选择更大范围的 alpha 值,以便图形图元看起来更平滑。这种平滑度是通过组合源像素和当前目标像素的红色、绿色和蓝色分量(其值的范围从 0[最暗]到 255[最亮])的百分比而得到的,以便当绘制新的目标像素时,当前目标像素的一部分显示出来。

渲染过程的最后一步是将源像素与目标像素相结合。这一步是根据当前的复合规则执行的,复合规则决定了这种组合是如何发生的。

复合规则考虑了字母值百分比。例如,“源覆盖”规则(这是最直观的)将源像素的 100%颜色(取决于其 alpha)与目标像素的颜色百分比(恰好是(255-源像素的 alpha 值)/255*100)相结合。

考虑一个 alpha 为 255 的源像素(它对最终颜色的贡献是 100%)。根据该等式,目标像素的 alpha 值为 0(占 0%),这意味着目标像素被完全覆盖。如果源像素的 alpha 为 0(无任何贡献),目标像素的 alpha 将为 255(贡献所有),这意味着源像素不可见。中间 alpha 值组合了源像素和目标像素的不同百分比。

渲染属性

现在您已经掌握了Graphics2D渲染管道的基础知识,您已经准备好进一步探索它的渲染属性了。为了帮助您进行探索,我创建了一个基于 Swing 的Graphics2DAttribDemo应用。图 7-17 显示了这个菜单驱动应用的初始屏幕。

images

图 7-17。从演示菜单中选择一个菜单项,查看相关属性的演示。

作画

Graphics2D声明void setPaint(Paint paint)用于设置绘画属性。将类实现了java.awt.Paint接口的任何对象传递给paint。调用Paint getPaint()返回当前的油漆。

几个类实现了Paint,包括java.awt包的ColorGradientPaintTexturePaint类。这些类的实例可以传递给setPaint()或从getPaint()返回。

images Graphics类的setColor()方法相当于调用setPaint()

Color让您创建纯色。在它的各种构造函数中有Color(int r, int g, int b)用于创建不透明的纯色和Color(int r, int g, int b, int a)用于创建带有 alpha 值的纯色。

您并不局限于指定范围从 0 到 255 的基于整数的组件值。如果您喜欢范围从 0.0 到 1.0 的值,您可以调用诸如Color(float r, float g, float b)Color(float r, float g, float b, float a)这样的构造函数。

为了方便起见,Color声明了几个预先创建的Color常量:BLACKBLUECYANDARK_GRAYGRAYGREENLIGHT_GRAYMAGENTAORANGEPINKREDWHITEYELLOW。尽管全小写的变体是可用的,但是您应该避免使用它们——常量应该是大写的。

GradientPaint让您创建渐变。它声明了几个构造函数,包括GradientPaint(float x1, float y1, Color color1, float x2, float y2, Color color2),它描述了在用户空间中从左上角(x1y1)过渡到左下角(x2y2)的渐变。(x1y1)处的颜色为color1,(x2y2)处的颜色为color2

images 注意 Java 6 引入了一个抽象的java.awt.MultipleGradientPaint类和具体的java.awt.LinearGradientPaintjava.awt.RadialGradientPaint子类来创建基于多种(通常多于两种)颜色的不同种类的渐变。我探索了这些类,并在我的“Java 2D 多色渐变绘画”教程([tutortutor.ca/cgi-bin/makepage.cgi?/tutorials/ct/j2dmcgp](http://tutortutor.ca/cgi-bin/makepage.cgi?/tutorials/ct/j2dmcgp))中展示了演示。

TexturePaint让你创建一个纹理。它声明了一个TexturePaint(BufferedImage txtr, Rectangle2D anchor)构造函数,用于从缓冲图像(指定纹理所基于的图像)和矩形锚(标识要复制的图像的矩形部分)的组合中创建纹理。

图 7-18 展示了纯色(左上角)、渐变(右上角)和纹理(底部)颜料。

images

图 7-18。从演示菜单中选择油漆,查看油漆演示。

中风

Graphics2D声明void setStroke(Stroke stroke)用于设置笔画属性。将类实现了java.awt.Stroke接口的任何对象传递给stroke。调用Stroke getStroke()返回当前笔画。

抚摸是如何工作的

描边是画出形状轮廓的动作。第一步是调用setStroke()来指定轮廓的绘制方式(例如,它的宽度以及它是实心的还是由虚线和空格混合组成)。下一步是调用setPaint()来指定如何绘制轮廓(例如,使用纯色、渐变或纹理)。最后一步是通过Graphics2Ddraw()方法画出轮廓。

当调用draw()时,Graphics2D使用传递给setStroke()的对象来计算轮廓的样子,并使用传递给setPaint()的对象来绘制轮廓的像素。

唯一实现该接口的类是java.awt.BasicStroke,它允许您根据钢笔宽度(垂直于钢笔轨迹测量)、端帽、连接样式、斜接限制和破折号属性来定义形状轮廓。

形状的轮廓是无限细的,并且是用具有特定宽度的笔绘制的,该宽度表示为浮点值。生成的轮廓形状延伸到该轮廓之外,并延伸到形状的内部。

线段两端可以画有或没有装饰。这些装饰被称为端盖BasicStroke声明CAP_BUTTCAP_ROUNDCAP_SQUARE常量以指示不存在端帽,半径等于笔宽一半的半圆出现在两端,或者长度等于笔宽一半的矩形出现在两端。

当两条线段相遇时,Graphics2D使用连接样式将它们连接在一起,这样它们就不会呈现出参差不齐的边缘。BasicStroke声明JOIN_BEVELJOIN_MITERJOIN_ROUND常量,以指示斜面(方形)、斜接(削尖为三角形点)或圆形连接。当斜接超出指定的斜接限制时,连接将被斜接。

最后,BasicStroke允许您通过提供虚线数组和虚线相位值来指定虚线。虚线数组包含代表可见和不可见线段的用户空间长度的浮点值。偶数索引数组元素确定可见部分的长度;奇数索引数组元素确定不可见部分的长度。

例如,考虑一个[8.0,6.0]的破折号数组。该数组的第一个(偶数)元素表示可见线段的长度为 8.0 个单位,第二个(奇数)元素表示不可见线段的长度为 6.0 个单位。你最终得到 8 个可见单元,6 个不可见单元,8 个可见单元,6 个不可见单元,等等。

虚线相位是由虚线数组指定的虚线模式的浮点偏移量;它不是数组中的偏移量。当虚线相位为 0 时,线段将被描边,如前一示例所示。但是,当指定非零虚线相位时,第一条线段从第一个数组条目提供的值开始虚线相位单位。

例如,给定前面的数组,假设您指定了 3.0 的破折号相位。该值表示第一个可见线段的长度为 8-3 或 5 个单位,后面是 6 个不可见单位、8 个可见单位、6 个不可见单位,依此类推。

BasicStroke声明了几个构造函数,包括BasicStroke(float width, int cap, int join, float miterlimit, float[] dash, float dash_phase),它可以让你完全控制笔画的特征,还有一个更短的BasicStroke(float width, int cap, int join)构造函数,它画的是实线。

演示了这两个构造函数以及笔宽、端帽、连接样式、斜接限制和破折号属性。这些特征如图 7-19 所示。

images

图 7-19。从演示菜单中选择笔画,查看笔画演示。

字体

Graphics2D继承Graphics ) void setFont(Font font)为指定的Font对象设置字体属性。调用Font getFont()(也继承自Graphics)返回当前字体。

图 7-20 显示了 Arial 字体的普通、粗体、斜体和粗体加斜体样式。

images

图 7-20。从演示菜单中选择字体,查看字体演示。

转型

Graphics2D包含一个内部转换矩阵(transform ),用于在渲染过程中对图元进行几何重定向。图元可以被转换(移动)、缩放(调整大小)、旋转、剪切(横向移动),或者以开发人员指定的其他方式进行转换。

内部变换矩阵是java.awt.geom.AffineTransform类的一个实例,保证直线映射到直线,平行线映射到平行线。初始仿射变换代表恒等式变换,其中没有任何变化(例如,不执行旋转)。

您可以用几种方法修改这个矩阵。例如,您可以调用Graphics2Dvoid setTransform(AffineTransform Tx)方法,用传递给Tx的仿射变换替换当前的变换矩阵。或者,您可以调用Graphics2Dvoid transform(AffineTransform Tx)方法将Tx连接到现有的转换矩阵。

images 提示使用transform()而不是setTransform()是一个好主意,因为传递给组件的paint()方法的Graphics2D实例是用默认转换设置的,它给出了图 7-7 所示的坐标系。调用setTransform()可能会改变这个组织,导致混乱的结果,除非你知道你在做什么。

对于常见的转换,Graphics2D声明了void rotate(double theta)void scale(double sx, double sy)void translate(double tx, double ty)等方法。这些方法为实例化AffineTransform并将该实例传递给transform()提供了一种方便的替代方法。

images 注意 Graphics2D声明一个void translate(int x, int y)方法,用于将Graphics2D上下文的原点平移到当前坐标系中的点(xy)。当您传递整数参数时,调用这个方法而不是translate(double, double),所以在传递参数时要小心,否则您可能会得到意想不到的结果。

图 7-21 显示了未变换(蓝色)、旋转(渐变绿色到红色)和剪切(渐变绿色到红色,几乎没有绿色)的矩形。

images

图 7-21。从演示菜单中选择转换,查看转换演示。

复合规则

Graphics2D声明void setComposite(Composite comp)用于设置复合规则属性。将类实现了java.awt.Composite接口的任何对象传递给comp。调用Composite getComposite()返回当前的复合规则。

唯一实现这个接口的类是java.awt.AlphaComposite,它实现了基本的 alpha 复合规则,用于组合源和目标颜色,以实现图形和图像的混合和透明效果。这个类实现的特定规则是 T. Porter 和 T. Duff 的“合成数字图像”论文(SIGGRAPH 1984,第 253 页–第 259 页)中描述的 12 个规则的基本集合。

AlphaComposite声明描述这些规则的CLEARDSTDST_ATOPDST_INDST_OUTDST_OVERSRCSRC_ATOPSRC_INSRC_OUTSRC_OVERXOR整数常量,默认为SRC_OVER。它还声明了预先创建的AlphaComposite实例常量,名为ClearDstDstAtopDstInDstOutDstOverSrcSrcAtopSrcInSrcOutSrcOverXor

这两组常数之间的差异与 alpha 值有关。预先创建的AlphaComposite实例被关联到 alpha 值 1.0(不透明)。整数常量和特定的浮点 alpha 值可以传递给AlphaCompositeAlphaComposite getInstance(int rule, float alpha)类方法。这个 alpha 值在用于AlphaComposite的 Java 文档中描述的混合公式之前,用于修改每个源像素的不透明度或覆盖范围。

图 7-22 显示了应用这些规则的结果。

images

图 7-22。从演示菜单中选择复合规则,查看复合规则演示。

图 7-22 向你展示了 alpha 值为 1.0 的规则。但是,您也可以使用getInstance(int rule, float alpha)来改变 alpha 值。我在图 7-17 中展示了这样做的结果。如果我没有这样做,你会看到如图 7-23 所示的窗口。

images

图 7-23。应用 alpha 值为 1.0 的默认SRC_OVER规则。

剪裁形状

Graphics2D声明void clip(Shape clipShape)并从Graphics继承void setClip(Shape clipShape)来设置裁剪形状属性。调用clip()使整体裁剪形状变小;调用setClip()使整体裁剪形状变大。将类实现了java.awt.Shape接口的任何对象传递给clipShape。调用Shape getClip()(继承自Graphics)返回当前裁剪形状;当剪辑形状是整个绘图图面时,返回 null。

Java 2D 提供了一组Shape实现类。此外,早于 Java 2D 的java.awt.Polygon类已经被改进来实现这个接口。以下示例演示了如何创建和安装基于Polygon的矩形剪辑:

Polygon polygon = new Polygon();
polygon.addPoint(30, 30);
polygon.addPoint(60, 30);
polygon.addPoint(60, 60);
polygon.addPoint(30, 60);
g.clip(polygon);

图 7-24 显示了安装夹子后,试图将整个图纸表面涂成绿色的结果。

images

图 7-24。从演示菜单中选择裁剪形状,查看裁剪形状演示。

渲染提示

Graphics2D声明void setRenderingHint(RenderingHints.Key hintKey, Object hintValue)用于设置光栅化器使用的渲染提示之一。调用它的同伴Object getRenderingHint(RenderingHints.Key hintKey)方法来返回指定呈现提示的当前值。

传递给hintKey的值是在RenderingHints类中声明的java.awt.RenderingHints.Key常量(例如KEY_ANTIALIASING)。该值是该类中声明的值常量之一(例如,VALUE_ANTIALIAS_ON)。

以下示例显示了如何激活抗锯齿功能:

g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                   RenderingHints.VALUE_ANTIALIAS_ON);

图 7-25 揭示了锯齿和抗锯齿文本之间的区别。

images

图 7-25。从演示菜单中选择渲染提示,查看渲染提示演示。

images 注意 Graphics2D也声明void setRenderingHints(Map<?,?> hints)用于丢弃所有渲染提示,只安装指定贴图中的那些渲染提示。

形状

Shape界面表示基于矢量的几何形状,如矩形或椭圆形。它声明了getBounds()contains()intersects()getPathIterator()方法:

  • getBounds()方法返回包围形状边界的矩形;这些矩形充当边界框。
  • 形状有内部和外部。方法告诉你一个点或者一个矩形是否在一个形状里面。
  • 方法告诉你矩形的任何部分是否与形状的内部相交。
  • getPathIterator()方法返回形状轮廓。

前三个方法类别在很多任务中都很有用,比如基于游戏的碰撞检测(两个形状占用同一个空间吗?)和基于图形应用的点击测试(按下鼠标按钮时,鼠标光标是否在特定的形状上?)—也许图形应用允许用户拖动选定的形状。后一种方法有助于渲染管道获得形状轮廓。

其中一个contains()方法带有一个java.awt.geom.Point2D参数。该类的实例指定用户空间中的点。(Point2D实例不是形状,因为Point2D没有实现Shape。)

Point2D揭示形状类遵循的模式。这个抽象类包含一对嵌套的DoubleFloat具体子类,它们覆盖了它的抽象方法。实例化Double以提高精度,实例化Float以提高性能。

以下示例显示了如何实例化Point2D,以指定用户空间中的点:

Point2D pt1 = new Point2D.Double(10.0, 20.0);
Point2D pt2 = new Point2D.Float(20.0f, 30.0f);

java.awt.geom包包含了实现Shape的各种几何类:Arc2DAreaCubicCurve2DEllipse2DGeneralPathLine2DPath2DQuadCurve2DRectangle2DRectangularShapeRoundRectangle2DRectangularShapeArc2DEllipse2DRectangle2DRoundRectangle2D的抽象超类。另外,我在本章前面介绍的java.awt.Rectangle类已经被改进来扩展Rectangle2D。最后,GeneralPath是一个继承的 final 类(你不能扩展它),它扩展了Path2D.Float

RoundRectangle2D描述具有特定半径圆角的矩形。它嵌套的DoubleFloat子类声明了无参数构造函数来构造一个新的RoundRectangle2D实例,该实例被初始化为位置(0.0,0.0)、大小(0.0,0.0)和半径为 0.0 的角弧。它们还声明了用于指定位置、大小和角弧的构造函数。

如果您调用无参数构造函数,您可以随后调用DoubleFloatsetRoundRect()方法来指定位置、大小和圆角半径。然而,如果你只有一个RoundRectangle2D引用(不是RoundRectangle2D.DoubleRoundRectangle2D.Float引用),你可以调用RoundRectangle2Dvoid setRoundRect(double x, double y, double w, double h, double arcWidth, double arcHeight)方法。(您会发现这个构造函数/集合模式在其他形状类中重复出现。)

我已经创建了一个演示了RoundRectangle2DShapeboolean contains(double x, double y)方法的DragRect应用。DragRect向你展示了如何在它的绘图表面上拖动这个圆形矩形,而清单 7-9 展示了它的源代码。

清单 7-9 。拖动一个圆角矩形

`import java.awt.Color;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.RenderingHints;

import java.awt.event.MouseEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseMotionAdapter;

import java.awt.geom.RoundRectangle2D;

import javax.swing.JComponent;
import javax.swing.JFrame;

class DragRect
{
   public static void main(String[] args) {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         JFrame f = new JFrame("Drag Rectangle");
                         f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                         f.setContentPane(new DragRectPane());
                         f.pack();
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}
final class DragRectPane extends JComponent
{
   private boolean dragging;
   private double dragX, dragY;
   private Dimension d;
   private RoundRectangle2D rect;
   DragRectPane()
   {
      d = new Dimension(200, 200);
      rect = new RoundRectangle2D.Double(0.0, 0.0, 30.0, 30.0, 10.0, 10.0);
      addMouseListener(new MouseAdapter()
                       {
                          @Override
                          public void mousePressed(MouseEvent me)
                          {
                             if (!rect.contains(me.getX(), me.getY()))
                                return;
                             dragX = me.getX();
                             dragY = me.getY();
                             dragging = true;
                          }
                          @Override
                          public void mouseReleased(MouseEvent me)
                          {
                             dragging = false;
                          }
                       });
      addMouseMotionListener(new MouseMotionAdapter()
                             {
                                @Override
                                public void mouseDragged(MouseEvent me)
                                {
                                   if (!dragging)
                                      return;
                                   double x = rect.getX()+me.getX()-dragX;
                                   double y = rect.getY()+me.getY()-dragY;                                    rect.setRoundRect(x, y, rect.getWidth(),
                                                     rect.getHeight(),
                                                     rect.getArcWidth(),
                                                     rect.getArcHeight());
                                   repaint();
                                   dragX = me.getX();
                                   dragY = me.getY();
                                }
                             });
   }
   @Override
   public Dimension getPreferredSize()
   {
      return d;
   }
   @Override
   public void paint(Graphics g)
   {
      Graphics2D g2d = (Graphics2D) g;
      g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
                           RenderingHints.VALUE_ANTIALIAS_ON);
      g2d.setColor(Color.RED);
      g2d.fill(rect);
   }
}`

清单 7-9 的DragRectPane类子类JComponent,给出了一个无参数构造函数,并覆盖了getPreferredSize()paint()

构造函数首先实例化DimensionRoundRectangle2D.Double,然后用这个组件注册鼠标和运动监听器。

当用户按下鼠标按钮启动拖动操作时(鼠标光标移动时按住鼠标按钮),鼠标监听器的void mousePressed(MouseEvent me)方法被调用。这个方法首先调用它的MouseEvent参数上的int getX()int getY()方法,以获得当鼠标按钮被按下时鼠标光标的组件相对位置。

这些鼠标坐标被传递给圆角矩形的contains()方法,以确定按钮按下时鼠标光标是否在该形状上。如果鼠标光标不在圆角矩形上,则此方法返回。

否则,鼠标坐标被保存在dragXdragY变量中,以记录拖动操作的原点,并且dragging布尔变量被赋值true,以便当拖动操作开始时,仅当鼠标光标在形状上时,形状才被拖动。

在拖动操作过程中,鼠标运动监听器的void mouseDragged(MouseEvent me)方法被调用。它的第一个任务是测试dragging,看看鼠标光标是否在形状上。如果此变量包含 false,此方法将返回。(如果没有此测试,当鼠标光标不在圆角矩形上时按下鼠标按钮,然后开始拖动鼠标光标,会导致形状吸附到拖动位置,随后被拖动。)

如果dragging包含 true,mouseDragged()接着通过用当前鼠标坐标和保存在dragXdragY中的坐标之间的差值来偏移当前原点,计算圆角矩形的新左上角原点。然后它通过一个setRoundRect()方法调用将新的原点连同当前尺寸和圆弧半径传递给圆角矩形。

继续,对repaint()的调用导致圆角矩形在新位置被重新绘制,并且一对赋值语句将dragXdragY更新到当前鼠标坐标,以便对mouseDragged()的下一个调用计算相对于刚刚计算的原点的新圆角矩形原点。

当鼠标按钮被释放时,鼠标监听器的void mouseReleased(MouseEvent me)方法被调用。该方法将false分配给dragging,这样当拖动操作随后开始时,形状不会被拖动,但是当操作开始时,鼠标光标不会在形状上。

编译这个源代码(javac DragRect.java)并运行应用(java DragRect)。图 7-26 显示了拖拽操作正在进行的结果 GUI。

images

图 7-26。将鼠标指针放在圆形矩形上,开始拖动该形状。

建设性区域几何图形

构造性区域几何(CAG) 通过对两个现有形状执行布尔运算来创建一个新形状。这些操作包括布尔 OR(创建一个合并了现有形状像素的新形状)、布尔 NOT(创建一个仅包含一个形状中的像素但不包含另一个形状中的像素的新形状)、布尔 AND(创建一个仅包含重叠像素的新形状)和布尔 XOR(创建一个仅包含不重叠像素的新形状)。布尔 OR 又称为并集,布尔 NOT 又称为减法,布尔 and 又称为交集。

Java 2D 通过其void add(Area rhs)【联合】、void subtract(Area rhs)void intersect(Area rhs)void exclusiveOr(Area rhs)方法提供了用于执行布尔运算的java.awt.geom.Area类。每个方法对当前的Area对象及其Area对象参数执行指定的布尔运算,并将结果存储在当前的Area对象中。

要使用Area,首先将一个Shape对象传递给它的Area(Shape s)构造函数,然后在这个Area对象上调用上述方法之一来执行操作。因为Area也实现了Shape,所以可以将带有布尔结果的Area对象传递给Graphics2Ddraw()fill()方法。

下面的示例演示了对一对椭圆的联合运算:

Ellipse2D ell1 = new Ellipse2D.Double(10.0, 10.0, 40.0, 40.0);
Ellipse2D ell2 = new Ellipse2D.Double(30.0, 10.0, 40.0, 40.0);
Area area1 = new Area(ell1);
Area area2 = new Area(ell2);
area1.add(area2);

创建两个椭圆形状后,该示例创建两个Area对象,其中每个对象包含一个椭圆。然后调用第一个Area对象上的add(),在从左上角(10.010.0)到右下角(70.050.0)的区域内创建一个像素联合。结果存储在第一个Area对象中。

我已经创建了一个 CAG 应用来演示这些布尔运算——该应用的源代码可以在本书附带的代码文件中找到。该应用的输出显示在图 7-27 的中。

images

图 7-27。将鼠标指针放在圆形矩形上,开始拖动该形状。

缓冲图像

AWT 的Image类与彩色像素的矩形阵列相关联。虽然您可以绘制这些像素(通过drawImage()),但是您需要使用 AWT 有些乏味的生产者/消费者模型(为了简洁起见,我不讨论这个模型)来访问它们。相比之下,Java 2D 的java.awt.image.BufferedImage类扩展了Image,使得这些像素对应用可用,并且更容易使用。

例如,您可以调用BufferedImageint getWidth()int getHeight()方法来获得图像的宽度和高度,而无需处理图像观察者。(因为BufferedImage扩展了Image,面向图像观察者的宽度和高度获取方法也是可用的。)

BufferedImage声明了三个构造函数,其中BufferedImage(int width, int height, int imageType)是最简单的。传递给此构造函数的参数标识缓冲图像的宽度(以像素为单位)、高度(以像素为单位)和类型(用于存储像素的格式)。

虽然BufferedImage声明了几个类型常量,但是通常使用的是TYPE_INT_RGB(每个像素都有红色、绿色和蓝色分量,但没有 alpha 分量)TYPE_INT_ARGB(每个像素都有 alpha、红色、绿色和蓝色分量)TYPE_INT_ARGB_PRE(与TYPE_INT_ARGB相同,只是每个像素的颜色分量值都与其 alpha 值预乘)。

images 注意渲染管道的合成部分通常需要将每个像素的颜色分量乘以其 alpha 值。因为这需要时间,BufferedImage允许您通过预乘每个像素的颜色分量并将结果存储为新的颜色分量值来优化此过程。

以下示例实例化BufferedImage来描述存储 RGB 类型像素的 100 列 50 行缓冲图像:

BufferedImage bi = new BufferedImage(100, 50, BufferedImage.TYPE_INT_RGB);

BufferedImage将每个像素的颜色分量归零,使图像最初为空。如果缓冲的图像是TYPE_INT_RGB,这些像素是黑色的。如果缓冲的图像是TYPE_INT_ARGB,这些像素是透明的。在目标上绘制透明缓冲图像只会显示目标像素。

填充缓冲图像的一种方法是调用它的void setRGB(int x, int y, int rgb)方法。setRGB()将像素(xy)设置为 32 位rgb值。如果指定一个 alpha 分量(最高 8 位),当类型为TYPE_INT_RGB时,alpha 分量被忽略。然而,当类型为TYPE_INT_ARGB时,alpha 分量与红色、绿色和蓝色分量一起存储。

以下示例将先前创建的缓冲图像的一个像素设置为特定值:

bi.setRGB(10, 10, 0x80ff0000);

本例将(1010)处的像素设置为0x80ff0000。您将这个 32 位十六进制值(从左到右)解释为 50%半透明、亮红色、无绿色和无蓝色。因为缓冲图像是作为TYPE_INT_RGB创建的,所以 alpha 分量被忽略。

您可以通过调用int getRGB(int x, int y)来访问像素值。以下示例返回存储在位置(1010)的值:

int rgb = bi.getRGB(10, 10);

images 注意不管缓冲图像的类型如何,setRGB()getRGB()方法总是访问缓冲图像,就好像它是以 RGB/ARGB 格式创建的一样。setRGB()getRGB()与底层格式相互转换。

填充缓冲图像的另一种方法是创建一个Image实例,并在图像完全加载后将它的相关图像绘制到缓冲图像上。您可以按如下方式完成此任务:

Image image = Toolkit.getDefaultToolkit().getImage("image.png");
MediaTracker mt = new MediaTracker(this); // this represents current component
mt.addImage(image, 1);
try { mt.waitForID(1); } catch (InterruptedException ie) { assert false; }
BufferedImage bi = new BufferedImage(image.getWidth(null), image.getHeight(null),
                                     BufferedImage.TYPE_INT_ARGB);
Graphics2D bg = bi.createGraphics();
bg.drawImage(image, 0, 0, null);
bg.dispose(); // Always dispose of a created Graphics2D context.

我将TYPE_INT_ARGB指定为缓冲图像的类型,因为 PNG 图像与 alpha 通道相关联。此外,我将null传递给了getWidth()getHeight()drawImage(),因为在图像被完全加载后就不需要图像观察器了。

BufferedImage声明一个Graphics2D createGraphics()方法,该方法返回一个Graphics2D实例,用于在缓冲图像上绘制图像或图形。完成绘制后,您必须处理此上下文。

前面的例子很冗长,因为它在绘制之前使用了MediaTracker来加载图像。您可以通过使用 Swing 的ImageIcon类来消除图像加载的冗长性,如下所示:

ImageIcon ii = new ImageIcon("image.png");
BufferedImage bi = new BufferedImage(ii.getIconWidth(), ii.getIconHeight(),
                                     BufferedImage.TYPE_INT_ARGB);
Graphics2D bg = bi.createGraphics();
bg.drawImage(ii.getImage(), 0, 0, null);
bg.dispose();
缓冲图像架构

现在你已经有足够的知识来处理缓冲图像了。(我还在附录 c 中向您展示了如何将缓冲图像保存到文件中。)但是,因为您可能希望提高使用缓冲图像的应用代码的性能(或者出于其他原因),您还应该了解缓冲图像架构。考虑图 7-28 。

images

图 7-28。缓冲图像包含一个光栅和一个颜色模型。

图 7-28 显示了一个缓冲图像封装了一个光栅和一个颜色模型。光栅根据提供颜色查找信息的样本值存储每个像素。对于 RGB 图像,每个像素存储三个样本,而对于 ARGB 图像存储四个样本。

样本存储在一个数据缓冲区中,该缓冲区由一个或多个原始类型值(如字节或短整数)的数组组成。对于 RGB 图像,所有红色样本将存储在一个阵列中,所有绿色样本将存储在第二个阵列中,所有蓝色样本将存储在第三个阵列中。每个样本阵列被称为波段通道

与数据缓冲区相关联的是一个样本模型,它与数据缓冲区通信以存储样本值并代表栅格检索样本值。

最后,颜色模型根据特定的颜色空间将像素的样本解释为一种颜色(参见[en.wikipedia.org/wiki/Color_space](http://en.wikipedia.org/wiki/color_space))。

当您调用getRGB()来获取像素的红色、绿色、蓝色和(可能)alpha 分量(取决于缓冲区的类型)时,该方法告诉栅格获取像素样本,栅格告诉样本模型查找样本,样本模型从数据缓冲区获取样本,并将它们传递给栅格,栅格将它们传递给getRGB()。此时,getRGB()告诉颜色模型将样本转换成颜色信息。颜色模型使用颜色空间来帮助它执行这种转换。

当您调用setRGB()来设置像素的颜色分量时,该方法告诉颜色模型获取对应于颜色分量的样本值,然后告诉光栅存储这些样本。栅格告诉样本模型存储像素的样本,样本模型将这些样本存储在数据缓冲区中。

images 注意光栅和颜色模型需要兼容。换句话说,样本数量(每像素)必须等于颜色模型组件的数量。

java.awt.image包包含一个用于描述只读栅格的具体Raster类、一个用于描述数据缓冲区的抽象DataBuffer类、一个用于描述样本模型的抽象SampleModel类和一个用于描述颜色模型的抽象ColorModel类。java.awt.color包包含一个抽象的ColorSpace类,用于描述色彩空间。查阅 Java 文档以了解更多关于这些类及其子类的信息(例如,RasterWritableRaster子类)。

缓冲图像处理

图像处理是信号处理([en.wikipedia.org/wiki/Signal_processing](http://en.wikipedia.org/wiki/signal_processing))的一种形式,其中数学变换将数字图像转换成其他数字图像。变换的作用是模糊、锐化、着色、浮雕、棕褐色调,以及对图像应用其他类型的操作。

通过提供java.awt.image.BufferedImageOpjava.awt.image.RasterOp接口,Java 2D 允许您处理缓冲图像或它们的栅格。虽然这些接口是相似的(例如,每个接口都声明了执行相同任务的五个方法),但是它们的不同之处在于BufferedImageOp可以访问缓冲图像的颜色模型,而RasterOp不能访问颜色模型。另外,RasterOpBufferedImageOp更具表演性,但合作起来也更复杂一些。

images BufferedImageOp和/或RasterOp实现称为图像操作符。它们也被称为滤镜,因为每个接口都声明了一个filter()方法——滤镜用于摄影。

BufferedImageOp接口的核心方法是BufferedImage filter(BufferedImage src, BufferedImage dest),它将源BufferedImage实例的内容过滤(转换)成存储在目标BufferedImage实例中的结果。如果两个缓冲图像的颜色模型不匹配,则执行到目标缓冲图像的颜色模型的颜色转换。如果您将null传递给dest,就会创建一个带有适当ColorModel实例的BufferedImage实例。当源和/或目标缓冲图像与实现该接口的类所允许的图像类型不兼容时,该方法抛出java.lang.IllegalArgumentException

RasterOp接口的核心方法是WritableRaster filter(Raster src, WritableRaster dest),它将源Raster实例的内容过滤成存储在目标WritableRaster实例中的结果。如果将null传递给dest,就会创建一个WritableRaster实例。当源和/或目标栅格与实现该接口的类所允许的栅格类型不兼容时,该方法可能会抛出IllegalArgumentException

images 注意根据实现类的不同,BufferedImageOp和/或RasterOpfilter()方法可能允许就地过滤,其中源和目标缓冲图像/光栅是相同的。

Java 2D 提供了五个实现这两个接口的java.awt.image类:AffineTransformOpColorConvertOpConvolveOpLookupOpRescaleOp。此外,这个包提供了BandCombineOp类,它只实现了RasterOp:

  • AffineTransformOp对缓冲的图像颜色或光栅样本进行几何变换(如旋转)。
  • BandCombineOp根据一组系数值组合光栅样本阵列。您可以使用此类来反转颜色分量带的等效样本,并有效地执行其他操作。
  • ColorConvertOp将缓冲图像颜色/光栅样本从一个颜色空间转换到另一个颜色空间。
  • ConvolveOp允许您执行空间卷积(组合源像素和相邻像素的颜色/样本),如模糊和锐化。
  • LookupOp允许您通过查找表修改像素分量值。
  • RescaleOp将像素分量值乘以一个比例因子,然后向结果添加一个偏移量。这个类对于使图像变亮和变暗很有用(尽管查找表也可以用于这个目的)。

images 注意LookupOp``WritableRaster filter(Raster src, WritableRaster dst)方法的 Java 文档声明,当您将null传递给dst时,会创建一个新的栅格。然而,将null传递给dst会导致java.lang.NullPointerException被抛出。

卷积图像

ConvolveOp将源像素的 alpha(当存在时)和颜色分量的一部分与其紧邻像素的分量的一部分进行组合,以生成目标像素。要组合的每个像素的分量值的百分比从浮点值表中获得,该表被称为内核。组件的值乘以相应的内核值,然后对结果求和。每个和被箝位到最小值 0/0.0(最暗/透明)和最大值 255/1.0(最亮/不透明)。

ConvolveOp在图像上移动内核以卷积每个像素。内核的中心值(或最接近中心的值)应用于被卷积的源像素,而其他值应用于相邻像素。

除了中心值被设置为 1.0 之外,同一性内核的所有值都被设置为 0.0。这个特殊的内核不会改变图像,因为将源像素的分量值乘以 1.0 不会改变这些分量,而将相邻像素的分量值乘以 0.0 会得到 0.0 的值,这在添加到乘法结果时没有任何贡献。

内核由java.awt.image.Kernel类的实例表示。要创建一个内核,首先创建一个浮点百分比值的数组,然后将这个数组连同表的宽度(列数)和高度(行数)一起传递给Kernel(int width, int height, float[] data)构造函数。

以下示例显示了如何创建基于身份的内核:

float[] identityKernel =
{
   0.0f, 0.0f, 0.0f,
   0.0f, 1.0f, 0.0f,
   0.0f, 0.0f, 0.0f
};
Kernel kernel = new Kernel(3, 3, identityKernel);

这个内核描述了一个应用于每个源像素及其八个紧邻像素的 3x 3 值表。要涉及更多的邻居,增加浮点数组的大小和的行数和列数。例如,您可以创建一个 5 乘 5 的内核,其中包含源像素及其 24 个紧邻像素。

images 注意虽然Kernel不需要奇数的宽度和高度参数,但是您可能会发现奇数列和奇数行的内核更容易理解。

创建内核后,您需要考虑当内核位于图像边缘的像素上时会发生什么。一些内核元素将没有相应的图像像素。例如,当 3 乘 3 内核被定位成其中心行在顶部图像行上方时,内核的顶部邻居值行没有对应的图像像素行。

ConvolveOp通过声明EDGE_ZERO_FILLEDGE_NO_OP常量来解决这种情况。指定EDGE_ZERO_FILL会导致ConvolveOp将边缘目标像素设置为零,这被解释为黑色(RGB)或透明(ARGB)。EDGE_NO_OP使ConvolveOp将源边缘像素不变地复制到目标。

要使用这个内核执行卷积,首先实例化ConvolveOp,如下所示:

BufferedImageOp identityOp = new ConvolveOp(kernel);
RasterOp identityOp = new ConvolveOp(kernel);

ConvolveOp(Kernel kernel)构造函数将边缘行为设置为EDGE_ZERO_FILL

images 提示使用ConvolveOp(Kernel kernel, int edgeCondition, RenderingHints hints)构造函数选择边缘行为和渲染提示,以控制光栅化器。

通过调用filter()方法继续,如下所示:

BufferedImage biResult = identityOp.filter(bi, null);
WriteableRaster wrResult = identityOp.filter(bi.getRaster(), null);

第一个filter()方法调用被传递给一个名为bi的现有BufferedImage实例作为它的第一个参数。它的第二个参数是null,告诉filter()创建一个新的BufferedImage实例作为目的地。您不能将同一个BufferedImage实例作为第二个参数传递,因为ConvolveOp不支持缓冲图像的就地过滤。

第二个filter()方法调用被传递缓冲图像的光栅(通过调用BufferedImageWritableRaster getRaster()方法获得)作为它的第一个参数。它也被作为第二个参数传递给null,因为ConvolveOp不支持栅格的就地过滤。

images 为了方便起见,我重点介绍基于缓冲图像的处理。此外,我在本书代码中包含的一个BIP应用的上下文中演示了各种过滤器/图像操作符。

您可以创建一个模糊内核,通过组合等量的源像素和相邻像素分量值来模糊图像。生成的内核出现在这里:

float ninth = 1.0f/9.0f;
float[] blurKernel =
{
   ninth, ninth, ninth,
   ninth, ninth, ninth,
   ninth, ninth, ninth
};
Kernel kernel = new Kernel(3, 3, blurKernel);

图 7-29 显示了模糊内核的结果——与图 7-10 相比较。

images

图 7-29。从“处理”菜单中选择“模糊”来模糊图像。

如果您要将模糊内核应用到 ARGB 图像,其中所有 alpha 分量值都是 255(或 1.0)以表示不透明图像,则目标图像的 alpha 值将与源图像的 alpha 值相同。原因是模糊内核将源和相邻 alpha 值除以 9,然后将结果相加,得到源像素的原始 alpha 值。

您可以通过从源像素分量中减去相邻像素分量来创建强调图像边缘的边缘内核。生成的内核出现在这里:

float[] edgeKernel =
{
    0.0f, -1.0f,  0.0f,
   -1.0f,  4.0f, -1.0f,
    0.0f, -1.0f,  0.0f
};
Kernel kernel = new Kernel(3, 3, edgeKernel);

图 7-30 显示了边缘内核的结果。

images

图 7-30。从处理菜单中选择边缘,生成仅显示边缘的图像。

如果您要将边缘内核应用于 ARGB 图像,其中所有 alpha 分量值都是 255/1.0,则目标图像将是透明的,因为边缘内核会将每个 alpha 值设置为 0(透明)。

最后,您可以通过将身份内核添加到边缘内核来创建锐化内核。生成的内核如下所示:

float[] sharpenKernel =
{
    0.0f, -1.0f,  0.0f,
   -1.0f,  5.0f, -1.0f,
    0.0f, -1.0f,  0.0f
};
Kernel kernel = new Kernel(3, 3, sharpenKernel);

图 7-31 显示了锐化内核的结果。

images

图 7-31。从处理菜单中选择锐化来锐化图像。

如果您要将锐化内核应用于 ARGB 图像,其中所有 alpha 分量值都是 255/1.0,则目标图像的 alpha 值将与源图像的 alpha 值相同。原因是锐化内核将源像素的 alpha 值乘以 4,并从相乘结果中减去其相邻像素的四个 alpha 值,得到源像素的原始 alpha 值。

images 注意内核的元素总和为 1.0,保持图像的亮度,如模糊和锐化内核所示。元素总和小于 1.0 的核生成较暗的图像,边缘核证明了这一点。元素总和大于 1.0 的核生成更亮的图像。

使用查找表

LookupOp允许您使用查找表处理缓冲图像,其中查找表包含一个或多个由像素分量值索引的值数组。

查找表由抽象的java.awt.image.LookupTable类的具体子类来描述,具体来说就是java.awt.image.ByteLookupTablejava.awt.image.ShortLookupTable,它们分别存储字节整数和短整数。这两个子类都可以使用,尽管您可能会使用ShortLookupTable,因为您可以很容易地表示无符号字节整数。相反,当选择ByteLookupTable时,您必须使用负值来表示从 128 到 255 的字节值。

要创建适用于所有组件的短整数查找表,首先创建其基础数组,如下所示:

short[] invert = new short[256];
for (int i = 0; i < invert.length; i++)
   invert[i] = (short) 255-i;

该数组旨在反转像素的颜色(和 alpha,如果存在的话)分量,并用于ShortLookupTable

通过实例化ShortLookupTable继续,如下所示:

LookupTable table = new ShortLookupTable(0, invert);

第一个参数是在索引到数组之前要从组件值中减去的偏移量。我传递0是因为我不想减去一个偏移量。第二个参数是数组本身。

最后,通过调用LookupOp(LookupTable lookup, RenderingHints hints)的构造函数实例化LookupOp,如下所示:

BufferedImageOp invertOp = new LookupOp(new ShortLookupTable(0, invert), null);

我选择不指定渲染提示,将null作为第二个参数传递。

在处理 ARGB 图像时使用单个数组有一个问题,因为它可能会破坏 alpha 通道——查找表也适用于 alpha。要解决这种情况,您可以通过为每个组件提供一个数组来单独处理 alpha 通道,如下所示:

short[] alpha = new short[256];
short[] red = new short[256];
short[] green = new short[256];
short[] blue = new short[256];
for (int i = 0; i < alpha.length; i++)
{
   alpha[i] = 255;
   red[i] = (short) (255-i);
   green[i] = (short) (255-i);
   blue[i] = (short) (255-i);
}
short[][] invert = { red, green, blue, alpha };
BufferedImageOp invertOp = new LookupOp(new ShortLookupTable(0, invert), null);

这个例子首先创建一个单独的数组来反转除 alpha 之外的每一个组件——每个alpha数组条目被赋予255来指定不透明。接下来,这些数组被传递给一个二维的invert数组——必须最后传递alpha数组。最后,使用二维invert数组和一个0偏移量作为参数调用另一个ShortLookupTable构造函数。结果表连同null(表示没有呈现提示)被传递给LookupOp的构造函数。

图 7-32 显示了该图像操作器的结果。

images

图 7-32。从处理菜单中选择负片,反转像素成分。

练习

以下练习旨在测试您对 AWT、Swing 和 Java 2D 的理解:

  1. 创建一个名为RandomCircles的 AWT 应用,它提供一个画布来显示填充的圆形椭圆(通过fillOval()呈现)。在应用启动时,以及每次鼠标指针出现在画布上时按下鼠标按钮,通过paint()方法显示一个新的随机颜色、随机位置、随机大小(宽度和高度为 5 到 35 个像素,每个范围使用相同的值)的填充圆。启动时,您可能会注意到画布首先显示一个随机颜色/位置/大小的圆,然后立即显示另一个。这与 AWT 在启动时至少调用两次paint()方法有关。因为你不知道paint()会被调用多少次,所以千万不要依靠paint()来改变组件的状态。相反,您必须仅使用此方法来呈现组件以响应当前状态。
  2. 修改清单 7-6 的基于 Swing 的TempVerter应用,在创建 GUI 之前安装 Nimbus 外观。
  3. Swing 的AbstractButton类(由JButton扩展)声明了一个void setMnemonic(int mnemonic)方法,用于设置键盘助记符(内存辅助),作为点击鼠标按钮的键盘快捷方式。传递给mnemonic的参数是在KeyEvent类中声明的虚拟键常量之一(例如VK_C)。当您调用此方法时,由助记符定义的第一个出现的字符(在按钮标签上从左到右)带有下划线。当您使用当前外观的无鼠标修饰键(通常是Alt键)按下该键时,按钮被单击。修改清单 7-6 的基于 Swing 的TempVerter应用,将VK_C助记符分配给转换为摄氏按钮,将VK_F助记符分配给华氏按钮。
  4. 由清单 7-3 的SplashCanvas类创建的伪启动屏幕遭受锯齿的困扰,使得文本和图形看起来参差不齐。您可以通过在渲染图形之前安装抗锯齿渲染提示来解决此问题。创建一个利用抗锯齿的新版本的SplashCanvas
  5. 添加上一个练习对抗锯齿的支持会降低渲染速度。因此,您可能会注意到重新绘制伪闪屏需要时间,并且 GUI 对鼠标点击的响应变得缓慢。您可以通过借助BufferedImage类预先创建非反转和反转图像来解决不可调整组件的这个问题。创建一个新版本的SplashCanvas来完成这个任务。
  6. 因为你可能会发现图 7-29 的模糊图像很难与图 7-10 区分开来,所以修改BIP添加一个模糊更多的菜单项。关联的监听器将创建一个 5 乘 5 元素的内核,其中每个元素都设置为1.0f/25.0f。涉及更多的相邻像素会导致更多的模糊。比较模糊更多和模糊的结果来看看自己。
  7. ColorSpaceColorConvertOp类可用于创建彩色图像的灰度版本。向BIP引入一个灰度菜单项,并让其关联的监听器使用这些类来生成玫瑰的灰度版本。

总结

抽象窗口工具包是 Java 独创的独立于窗口系统的 API,用于创建基于组件、容器、布局管理器和事件的 GUI。AWT 还支持图形、颜色、字体、图像、数据传输等等。

Swing 是一个独立于窗口系统的 API,用于创建基于组件、容器、布局管理器和事件的 GUI。虽然 Swing 扩展了 AWT(您可以在 Swing GUI 中使用 AWT 布局管理器和事件),但这个 API 与它的前身不同,因为 Swing GUI 在任何窗口系统上运行时都具有相同的外观,或者(由开发人员决定)采用它所运行的窗口系统的外观。此外,Swing 的非容器组件和一些容器完全由 Java 管理,因此它们可以拥有任何必要的特性(比如工具提示);这些功能不受窗口系统的影响。此外,Swing 可以提供并非在每个窗口系统上都可用的组件;例如,表格和树。

最后,Java 2D 是 AWT 扩展的集合,提供高级的二维图形、文本和图像功能。这个 API 为通过艺术线条、文本和图像开发更丰富的 GUI 提供了一个灵活的框架。

应用经常与文件系统交互,以向文件输出数据和/或从文件输入数据。第八章向你介绍标准类库的经典 I/O API 来完成这些任务。

八、与文件系统交互

应用经常与文件系统交互,以向文件输出数据和/或从文件输入数据。Java 的标准类库通过其经典的FileRandomAccessFile、流和写/读 API 支持文件系统访问。第八章向你介绍FileRandomAccessFile,以及各种流和写/读 API。

images 注意虽然通过 Java 的新 I/O API 访问文件系统是首选,但我不在本章讨论新 I/O,因为新 I/O 的一些方面涉及到网络,我直到第九章才讨论。此外,您应该了解本章的经典 I/O API,因为您会在修改使用经典 I/O 的遗留代码时遇到它们。

文件

应用经常与一个文件系统交互,该文件系统通常表示为从根目录开始的文件和目录的层次结构。

运行 Java 虚拟机(JVM)的 Windows 和其他平台通常支持至少一个文件系统。例如,Unix 或 Linux 平台将所有挂载的(附加和准备好的)磁盘组合成一个虚拟文件系统。相比之下,Windows 将单独的文件系统与每个活动磁盘驱动器相关联。

Java 通过其具体的java.io.File类提供对平台可用文件系统的访问。File声明了File[] listRoots()类方法来返回可用文件系统的根目录(root ),作为一个File对象的数组。

images 注意可用文件系统根的设置受到各种平台级操作的影响,例如插入或弹出可移动介质,以及断开或卸载物理或虚拟磁盘驱动器。

清单 8-1 展示了一个DumpRoots应用,它使用listRoots()获得一个可用文件系统根的数组,然后输出数组的内容。

清单 8-1。将可用的文件系统根目录转储到标准输出设备

import java.io.File;

class DumpRoots
{
   public static void main(String[] args)
   {
      File[] roots = File.listRoots();
      for (File root: roots)
         System.out.println(root);
   }
}

当我在我的 Windows XP 平台上运行这个应用时,我收到了下面的输出,它显示了四个可用的根:

A:\
C:\
D:\
E:\

如果我在 Unix 或 Linux 平台上运行DumpRoots,我将收到一个由虚拟文件系统根(/)组成的输出行。

除了使用listRoots()之外,您还可以通过调用File构造函数(如File(String pathname))获得一个File实例,该构造函数创建一个存储pathname字符串的File实例。下列赋值语句演示了此构造函数:

File file1 = new File("/x/y");
File file2 = new File("C:\\temp\\x.dat");

第一条语句假设使用 Unix 或 Linux 平台,以根目录符号/开始路径名,然后是目录名x,分隔符/,以及文件或目录名y。(它也适用于 Windows,Windows 假定该路径从当前驱动器的根目录开始。)

images 注意路径是一个目录层次结构,必须遍历它才能定位文件或目录。路径名是路径的字符串表示;与平台相关的分隔符(如 Windows 反斜杠[ \ ]字符)出现在连续名称之间。

第二条语句假设使用 Windows 平台,以驱动器说明符C:开始路径名,然后是根目录符号\,目录名temp,分隔符\,文件名x.dat(尽管x.dat可能指的是一个目录)。

images 注意字符串中出现的字符一定要用双反斜杠,特别是在指定路径名的时候;否则,您将面临出现错误或编译器错误消息的风险。例如,我在第二条语句中将反斜杠字符增加了一倍,以表示反斜杠而不是制表符(\t),并避免编译器错误消息(\x是非法的)。

每条语句的路径名都是一个绝对路径名,是一个以根目录符号开头的路径名;不需要其他信息来定位它所表示的文件/目录。相比之下,相对路径名不是以根目录符号开始的;它是通过从其他路径名获取的信息来解释的。

images 注意java.io包的类默认解析当前用户(也称为工作)目录的相对路径名,该目录由系统属性user.dir标识,通常是 JVM 启动的目录。(第四章展示了如何通过java.lang.System类的getProperty()方法读取系统属性。)

File实例通过存储抽象路径名来包含文件和目录路径名的抽象表示(这些文件或目录可能存在,也可能不存在于它们的文件系统中),这提供了独立于平台的分层路径名视图。相反,用户界面和操作系统使用依赖于平台的路径名字符串来命名文件和目录。

抽象路径名由可选的平台相关前缀字符串组成,如磁盘驱动器说明符“/”表示 Unix 根目录,或“\”表示 Windows 通用命名约定(UNC)路径名。和零个或多个字符串名称的序列。抽象路径名中的第一个名称可以是目录名,或者在 Windows UNC 路径名的情况下是主机名。每个后续名称表示一个目录;姓氏可以表示目录或文件。空抽象路径名没有前缀和空名称序列。

路径名字符串与抽象路径名之间的转换本质上是平台相关的。当路径名字符串转换为抽象路径名时,其中的名称由默认名称分隔符或底层平台支持的任何其他名称分隔符分隔。例如,File(String pathname)在 Unix 或 Linux 平台上将路径名字符串/x/y转换为抽象路径名/x/y,在 Windows 平台上将相同的路径名字符串转换为抽象路径名\x\y

images 默认名称分隔符可从系统属性file.separator中获得,也存储在FileseparatorseparatorChar类字段中。第一个字段将字符存储在一个java.lang.String实例中,第二个字段将其存储为一个char值。这些final字段的名称都不遵循完全大写的惯例。

当一个抽象路径名被转换成一个路径名字符串时,每个名称通过一个默认的名称分隔符与下一个名称分开。

File为实例化这个类提供了额外的构造函数。例如,下面的构造函数将父路径名和子路径名合并成存储在File对象中的组合路径名:

  • File(String parent, String child)从一个parent路径名字符串和一个child路径名字符串创建一个新的File实例。
  • File(File parent, String child)从一个parent路径名File实例和一个child路径名字符串创建一个新的File实例。

每个构造函数的parent参数都被传递了一个父路径名,这是一个由除了由child指定的姓氏之外的所有路径名组成的字符串。以下陈述通过File(String, String)展示了这一概念:

File file3 = new File("prj/books/", "bj7");

构造函数将相对父路径名prj/books/与子路径名bj7合并成相对路径名prj/books/bj7。(如果我指定了prj/books作为父路径名,构造函数就会在books后面添加分隔符。)

images 提示因为File(String pathname)File(String parent, String child)File(File parent, String child)不检测无效的路径名参数(除了当pathnamechildnull时抛出java.lang.NullPointerException,所以在指定路径名时一定要小心。您应该尽量只指定对应用运行的所有平台都有效的路径名。例如,不要在路径名中硬编码驱动器说明符(比如 C:),而是使用从listRoots()返回的根。更好的是,保持路径名相对于当前用户/工作目录(从user.dir系统属性返回)。

在获得一个File对象后,你可以通过调用表 8-1 中描述的方法来询问它以了解它存储的抽象路径名。

images

images

images

表 8-1 指的是IOException,它是那些描述各种 I/O 错误的异常类的公共异常超类,如java.io.FileNotFoundException

清单 8-2 用路径名命令行参数实例化File,并调用表 8-1 中描述的一些File方法来了解这个路径名。

清单 8-2。获取摘要路径名信息

import java.io.File;
import java.io.IOException;

class PathnameInfo
{
   public static void main(String[] args) throws IOException
   {
      if (args.length != 1)
      {
         System.err.println("usage: java PathnameInfo pathname");
         return;
      }
      File file = new File(args[0]);
      System.out.println("Absolute path = "+file.getAbsolutePath());
      System.out.println("Canonical path = "+file.getCanonicalPath());
      System.out.println("Name = "+file.getName());
      System.out.println("Parent = "+file.getParent());
      System.out.println("Path = "+file.getPath());
      System.out.println("Is absolute = "+file.isAbsolute());
   }
}

例如,当我指定java PathnameInfo .(句点代表我的 XP 平台上的当前目录)时,我观察到以下输出:

Absolute path = C:\prj\dev\bj7\ch08\code\PathnameInfo\.
Canonical path = C:\prj\dev\bj7\ch08\code\PathnameInfo
Name = .
Parent = null
Path = .
Is absolute = false

这个输出表明规范的路径名不包括句点。它还显示没有父路径名,并且路径名是相对的。

继续,我现在指定java PathnameInfo c:\reports\2011\..\2010\February。这一次,我观察到以下输出:

Absolute path = c:\reports\2011\..\2010\February Canonical path = C:\reports\2010\February Name = February Parent = c:\reports\2011\..\2010 Path = c:\reports\2011\..\2010\February Is absolute = true

这个输出表明规范路径名不包括2011。它还显示路径名是绝对的。

对于我的最后一个例子,假设我指定java PathnameInfo ""来获取空路径名的信息。作为响应,该应用生成以下输出:

Absolute path = C:\prj\dev\bj7\ch08\code\PathnameInfo
Canonical path = C:\prj\dev\bj7\ch08\code\PathnameInfo
Name =
Parent = null
Path =
Is absolute = false

输出显示getName()getPath()返回空字符串(""),因为空路径名为空。

您可以通过调用表 8-2 中描述的方法来询问文件系统,以了解由File对象的抽象路径名表示的文件或目录。

images

images

清单 8-3 用其路径名命令行参数实例化File,并调用表 8-2 中描述的所有File方法来了解路径名的文件/目录。

清单 8-3。获取文件/目录信息

`import java.io.File;
import java.io.IOException;

import java.util.Date;

class FileDirectoryInfo
{
   public static void main(final String[] args) throws IOException
   {
      if (args.length != 1)
      {
         System.err.println("usage: java FileDirectoryInfo pathname");
         return;
      }
      File file = new File(args[0]);
      System.out.println("About "+file+"😊;
      System.out.println("Can execute = "+file.canExecute());
      System.out.println("Can read = "+file.canRead());
      System.out.println("Can write = "+file.canWrite());
      System.out.println("Exists = "+file.exists());
      System.out.println("Is directory = "+file.isDirectory());
      System.out.println("Is file = "+file.isFile());
      System.out.println("Is hidden = "+file.isHidden());
      System.out.println("Last modified = "+new Date(file.lastModified()));
      System.out.println("Length = "+file.length());    }
}`

例如,假设我有一个名为x.dat的三字节只读文件。当我指定java FileDirectoryInfo x.dat时,我观察到以下输出:

About x.dat:
Can execute = true
Can read = true
Can write = true
Exists = true
Is directory = false
Is file = true
Is hidden = false
Last modified = Wed Aug 24 18:45:07 CDT 2011
Length = 3

images 注意 Java 6 向File添加了long getFreeSpace()long getTotalSpace()long getUsableSpace()方法,这些方法返回关于分区(文件系统的特定于平台的存储部分;例如,C:)由File实例的路径名描述。

File声明了五个方法,这些方法返回位于由File对象的抽象路径名标识的目录中的文件和目录的名称。表 8-3 描述了这些方法。

images

images

重载的list()方法返回表示文件和目录名的String数组。第二种方法让您通过基于java.io.FilenameFilter的过滤器对象只返回感兴趣的名字(比如只返回以扩展名.txt结尾的名字)。

FilenameFilter接口声明了一个单独的boolean accept(File dir, String name)方法,这个方法为位于由File对象的抽象路径名标识的目录中的每个文件/目录调用:

  • dir标识路径名的父部分(目录路径)。
  • name标识最终目录名或路径名的文件名部分。

accept()方法使用这些参数来确定文件或目录是否满足可接受的标准。当文件/目录名应该包含在返回的数组中时,它返回 true 否则,此方法返回 false。

清单 8-4 展示了一个Dir (ectory)应用,它使用list(FilenameFilter)来获取那些以特定扩展名结尾的名字。

清单 8-4。列举具体人名

`import java.io.File;
import java.io.FilenameFilter;

class Dir
{
   public static void main(final String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Dir dirpath ext");
         return;
      }
      File file = new File(args[0]);
      FilenameFilter fnf = new FilenameFilter()
                           {
                              public boolean accept(File dir, String name)
                              {
                                 return name.endsWith(args[1]);
                              }
                           };       String[] names = file.list(fnf);
      for (String name: names)
         System.out.println(name);
   }
}`

例如,当我在 XP 平台上指定java Dir c:\windows bmp时,Dir只输出那些扩展名为bmp(位图)的\windows目录文件名:

Blue Lace 16.bmp
Coffee Bean.bmp
FeatherTexture.bmp
Gone Fishing.bmp
Greenstone.bmp
Prairie Wind.bmp
Rhododendron.bmp
River Sumida.bmp
Santa Fe Stucco.bmp
Soap Bubbles.bmp
winnt.bmp
winnt256.bmp
Zapotec.bmp

重载的listFiles()方法返回File的数组。在很大程度上,它们与它们的list()方法是对称的。然而,listFiles(FileFilter)引入了一种不对称。

java.io.FileFilter接口声明了一个单独的boolean accept(String pathname)方法,这个方法为位于由File对象的抽象路径名标识的目录中的每个文件/目录调用。传递给pathname的参数标识了文件或目录的完整路径。

accept()方法使用这个参数来确定文件或目录是否满足可接受的标准。当文件/目录名应该包含在返回的数组中时,它返回 true 否则,此方法返回 false。

images 提示因为每个接口的accept()方法完成相同的任务,您可能想知道使用哪个接口。如果您喜欢一个分解成目录和命名组件的路径,请使用FilenameFilter。但是,如果您喜欢完整的路径名,请使用FileFilter;你可以随时呼叫getParent()getName()来获得这些组件。

File还声明了几个创建文件和操作现有文件的方法。表 8-4 描述了这些方法。

images

images

images

假设您正在设计一个文本编辑器应用,用户将使用它打开一个文本文件并对其内容进行更改。在用户将这些更改显式保存到文件之前,您希望文本文件保持不变。

因为用户不想在应用崩溃或计算机断电时丢失这些更改,所以您将应用设计为每隔几分钟将这些更改保存到一个临时文件中。这样,用户就有了更改的备份。

您可以使用重载的createTempFile()方法来创建临时文件。如果您没有指定一个目录来存储这个文件,那么它将被创建在由系统属性java.io.tmpdir标识的目录中。

在用户告诉应用保存或放弃更改后,您可能希望删除临时文件。方法让你注册一个临时文件来删除;当 JVM 在没有崩溃/断电的情况下结束时,它就会被删除。

清单 8-5 展示了一个TempFileDemo应用,让你试验createTempFile()deleteOnExit()方法。

清单 8-5。试验临时文件

import java.io.File;
import java.io.IOException;

class TempFileDemo
{
   public static void main(String[] args) throws IOException
   {
      System.out.println(System.getProperty("java.io.tmpdir"));
      File temp = File.createTempFile("text", ".txt");
      System.out.println(temp);
      temp.deleteOnExit();
   }
}

输出临时文件存放的位置后,TempFileDemo创建一个临时文件,文件名以text开头,扩展名为.txtTempFileDemo next 输出临时文件的名称,并注册临时文件,以便在应用成功终止时删除。

我在运行TempFileDemo的过程中观察到以下输出(文件在退出时消失):

C:\DOCUME~1\JEFFFR~1\LOCALS~1\Temp\
C:\DOCUME~1\JEFFFR~1\LOCALS~1\Temp\text3436502412322813057.txt

images 注意 Java 6 添加到了File中新的boolean setExecutable(boolean executable)boolean setExecutable(boolean executable, boolean ownerOnly)boolean setReadable(boolean readable)boolean setReadable(boolean readable, boolean ownerOnly)boolean setWritable(boolean writable)boolean setWritable(boolean writable, boolean ownerOnly)方法,这些方法允许您为由File对象的抽象路径名标识的文件设置所有者或每个人的执行、读取和写入权限。

最后,File实现了java.lang.Comparable接口的compareTo()方法,并覆盖了equals()hashCode()。表 8-5 描述了这些其他方法。

images

images

随机文件

可以为随机访问创建和/或打开文件,其中写和读操作可以发生,直到文件被关闭。Java 通过其具体的java.io.RandomAccessFile类支持这种随机访问。

RandomAccessFile声明了以下构造函数:

  • 如果文件不存在,创建并打开一个新文件,或打开一个现有文件。文件由file的抽象路径名标识,并根据mode创建和/或打开。
  • 如果文件不存在,创建并打开一个新文件,或打开一个现有文件。文件由pathname标识,并根据mode创建和/或打开。

任一构造函数的mode参数必须是"r""rw""rws""rwd"中的一个;否则,构造函数抛出IllegalArgumentException。这些字符串文字具有以下含义:

  • 通知构造器打开一个已有的只读文件。任何写入文件的尝试都会导致抛出一个IOException类的实例。
  • "rw"通知构造器创建并打开一个不存在的新文件进行读写,或者打开一个已存在的文件进行读写。
  • "rwd"通知构造器创建并打开一个不存在的新文件进行读写,或者打开一个已存在的文件进行读写。此外,对文件内容的每次更新都必须同步写入底层存储设备。
  • "rws"通知构造器在文件不存在时创建并打开一个新文件进行读写,或者打开一个已有的文件进行读写。此外,对文件内容或元数据的每次更新都必须同步写入底层存储设备。

images 注意文件的元数据是关于文件的数据,而不是实际的文件内容。元数据的例子包括文件的长度和文件最后修改的时间。

"rwd""rws"模式确保对位于本地存储设备上的文件的任何写入都被写入该设备,这保证了当操作系统崩溃时关键数据不会丢失。当文件不在本地设备上时,不做任何保证。

images 注意"rwd""rws"模式下打开的随机存取文件的操作比在"rw"模式下打开的随机存取文件的操作慢。

mode"r"pathname标识的文件无法打开时(可能不存在,也可能是目录),或者当mode"rw"pathname为只读或目录时,这些构造函数抛出FileNotFoundException

以下示例通过尝试使用"r"模式字符串打开现有的随机访问文件来演示第二个构造函数:

RandomAccessFile raf = new RandomAccessFile("employee.dat", "r");

一个随机存取文件与一个文件指针相关联,该指针标识下一个要写入或读取的字节的位置。当打开一个现有文件时,文件指针被设置为它的第一个字节,偏移量为 0。创建文件时,文件指针也被设置为 0。

写入和读取操作从文件指针开始,并使其前进超过写入或读取的字节数。超过文件当前结尾的写入操作会导致文件被扩展。这些操作会一直持续到文件关闭。

RandomAccessFile声明了各种各样的方法。我在表 8-6 中展示了这些方法的典型示例。

images

images

images

大多数表 8-6 的方法都是不言自明的。然而,getFD()方法需要进一步的启发。

images 注意RandomAccessFile``read-前缀方法和skipBytes()源自java.io.DataInput接口,这个类实现了这个接口。此外,RandomAccessFilewrite前缀方法源自java.io.DataOutput接口,该类也实现了该接口。

当打开一个文件时,底层平台创建一个依赖于平台的结构来表示该文件。这个结构的句柄存储在getFD()返回的java.io.FileDescriptor类的实例中。

images 注意一个句柄是 Java 传递给底层平台的标识符,在这种情况下,当它要求底层平台执行文件操作时,它标识一个特定的打开文件。

FileDescriptor是一个小类,声明了三个FileDescriptor常量,分别名为inouterr。这些常量让System.inSystem.outSystem.err提供对标准输入、标准输出和标准误差流的访问。

FileDescriptor还声明了一对方法:

  • void sync()告诉底层平台将打开文件的输出缓冲区的内容刷新(清空)到它们相关的本地磁盘设备。sync()将所有修改的数据和属性写入相关设备后返回。当缓冲区不能被刷新时,或者因为平台不能保证所有的缓冲区已经与物理媒体同步,它抛出java.io.SyncFailedException
  • boolean valid()确定该文件描述符对象是否有效。当文件描述符对象代表一个打开的文件或其他活动的 I/O 连接时,它返回 true 否则,它返回 false。

写入打开文件的数据最终被存储在底层平台的输出缓冲区中。当缓冲区填满时,平台会将它们清空到磁盘。缓冲区可以提高性能,因为磁盘访问速度很慢。

然而,当你向通过模式"rwd""rws"打开的随机存取文件写入数据时,每个写操作的数据都被直接写入磁盘。因此,写操作比在"rw"模式下打开随机存取文件时要慢。

假设您有这样一种情况,既通过输出缓冲区写入数据,又直接将数据写入磁盘。下面的例子通过以模式"rw"打开文件并有选择地调用FileDescriptorsync()方法来解决这个混合场景:

RandomAccessFile raf = new RandomAccessFile("employee.dat", "rw");
FileDescriptor fd = raf.getFD();
// Perform a critical write operation.
raf.write(...);
// Synchronize with underlying disk by flushing platform's output buffers to disk.
fd.sync();
// Perform non-critical write operation where synchronization is not necessary.
raf.write(...);
// Do other work.
// Close file, emptying output buffers to disk.
raf.close();

RandomAccessFile对于创建一个平面文件数据库很有用,一个组织成记录和字段的单个文件。记录存储单个条目(例如零件数据库中的零件),而字段存储条目的单个属性(例如零件号)。

平面文件数据库通常将其内容组织成一系列固定长度的记录。每个记录被进一步组织成一个或多个固定长度的字段。图 8-1 在零件数据库的背景下说明了这一概念。

images

图 8-1。这个平面文件数据库描述了汽车零件。

根据图 8-1 ,每个字段都有一个名称(零件号、desc、数量和成本)。此外,每个记录被分配一个从 0 开始的数字。这个例子由五条记录组成,为了简洁起见,只显示了其中的三条。

images 注意术语字段也用于指在类内声明的变量。为了避免与这种重载术语混淆,可以将字段变量想象成类似于记录的字段属性。

为了向您展示如何根据RandomAccessFile实现平面文件数据库,我创建了一个简单的PartsDB类来模拟图 8-1 。查看清单 8-6 。

清单 8-6。实施零件平面文件数据库

`import java.io.Closeable;
import java.io.IOException;
import java.io.RandomAccessFile;

class PartsDB implements Closeable
{
   final static int PNUMLEN = 20;
   final static int DESCLEN = 30;
   final static int QUANLEN = 4;
   final static int COSTLEN = 4;
   private final static int RECLEN = 2PNUMLEN+2DESCLEN+QUANLEN+COSTLEN;
   private RandomAccessFile raf;
   PartsDB(String pathname) throws IOException
   {
      raf = new RandomAccessFile(pathname, "rw");
   }
   void append(String partnum, String partdesc, int qty, int ucost)
      throws IOException
   { raf.seek(raf.length());
      write(partnum, partdesc, qty, ucost);
   }
   @Override
   public void close() throws IOException
   {
//      throw new IOException("cannot close raf");
      raf.close();
   }
   int numRecs() throws IOException
   {
      return (int) raf.length()/RECLEN;
   }
   Part select(int recno) throws IOException
   {
      if (recno < 0 || recno >= numRecs())
         throw new IllegalArgumentException(recno+" out of range");
      raf.seek(recnoRECLEN);
      return read();
   }
   void update(int recno, String partnum, String partdesc, int qty, int ucost)
      throws IOException
   {
      if (recno < 0 || recno >= numRecs())
         throw new IllegalArgumentException(recno+" out of range");
      raf.seek(recno
RECLEN);
      write(partnum, partdesc, qty, ucost);
   }
   private Part read() throws IOException
   {
      StringBuffer sb = new StringBuffer();
      for (int i = 0; i < PNUMLEN; i++)
         sb.append(raf.readChar());
      String partnum = sb.toString().trim();
      sb.setLength(0);
      for (int i = 0; i < DESCLEN; i++)
         sb.append(raf.readChar());
      String partdesc = sb.toString().trim();
      int qty = raf.readInt();
      int ucost = raf.readInt();
      return new Part(partnum, partdesc, qty, ucost);
   }
   private void write(String partnum, String partdesc, int qty, int ucost)
      throws IOException
   {
      StringBuffer sb = new StringBuffer(partnum);
      if (sb.length() > PNUMLEN)
         sb.setLength(PNUMLEN);
      else
      if (sb.length() < PNUMLEN)
      {
         int len = PNUMLEN-sb.length();          for (int i = 0; i < len; i++)
            sb.append(" ");
      }
      raf.writeChars(sb.toString());
      sb = new StringBuffer(partdesc);
      if (sb.length() > DESCLEN)
         sb.setLength(DESCLEN);
      else
      if (sb.length() < DESCLEN)
      {
         int len = DESCLEN-sb.length();
         for (int i = 0; i < len; i++)
            sb.append(" ");
      }
      raf.writeChars(sb.toString());
      raf.writeInt(qty);
      raf.writeInt(ucost);
   }
   static class Part
   {
      private String partnum;
      private String desc;
      private int qty;
      private int ucost;
      Part(String partnum, String desc, int qty, int ucost)
      {
         this.partnum = partnum;
         this.desc = desc;
         this.qty = qty;
         this.ucost = ucost;
      }
      String getDesc()
      {
         return desc;
      }
      String getPartnum()
      {
         return partnum;
      }
      int getQty()
      {
         return qty;
      }
      int getUnitCost()
      {
         return ucost;
      }
   }
}`

清单 8-6 的PartsDB类实现了java.io.Closeable接口,因此它可以在 try-with-resources 语句的上下文中使用(参见第三章)。我本可以选择实现、?? 的java.lang.AutoCloseable超接口,但却选择了Closeable,因为它的close()方法被声明为抛出IOException

PartsDB声明标识字符串和 32 位整数字段长度的常数。然后,它声明一个常数,以字节为单位计算记录长度。该计算考虑了一个字符在文件中占用两个字节的事实。

这些常量后面是一个名为raf的字段,它的类型是RandomAccessFile。在随后的构造函数中,这个字段被分配了一个RandomAccessFile类的实例,由于"rw",它创建/打开一个新文件或者打开一个现有文件。

PartsDB接下来声明append()close()numRecs()select()update()。这些方法将记录追加到文件中,关闭文件,返回文件中的记录数,选择并返回特定记录,以及更新特定记录:

  • append()方法首先调用length()seek()。这样做可以确保在调用私有的write()方法来写入包含该方法参数的记录之前,文件指针被定位到文件的末尾。
  • close()方法被声明为public,因为它是从Closeable继承的,并且接口方法是公共的——你不能让一个覆盖的方法更难访问。这个方法也被声明为抛出IOException,因为RandomAccessFileclose()方法可以抛出IOException。因为这种情况很少发生,所以我注释掉了一个 throw 语句,您可以用它来试验被抑制的异常——当我展示UsePartsDB时,我将向您展示如何这样做。
  • numRecs()方法返回文件中记录的数量。这些记录从 0 开始编号,以numRecs()-1结束。每个select()update()方法都验证它的recno参数在这个范围内。
  • select()方法调用私有的read()方法来返回由recno标识的记录,作为Part静态成员类的实例。Part的构造函数将一个Part对象初始化为记录的字段值,它的 getter 方法返回这些值。
  • update()方法同样简单。与select()一样,它首先将文件指针定位到由recno标识的记录的开始处。和append()一样,它调用write()写出它的参数,但是替换一个记录而不是添加一个记录。

记录是通过私有的write()方法写入的。因为字段必须有精确的大小,write()用右边的空格填充比字段大小短的基于String的值,并在需要时将这些值截断为字段大小。

通过私有的read()方法读取记录。read()在将基于String的字段值保存到Part对象之前,删除填充。

PartsDB本身是没用的。我们需要一个能让我们试验这个类的应用,清单 8-7 满足了这个需求。

清单 8-7。试验零件平面文件数据库

`import java.io.IOException;

class UsePartsDB
{
   public static void main(String[] args) {
      try (PartsDB pdb = new PartsDB("parts.db"))
      {
         if (pdb.numRecs() == 0)
         {
            // Populate the database with records.
            pdb.append("1-9009-3323-4x", "wiper blade micro edge", 30, 2468);
            pdb.append("1-3233-44923-7j", "parking brake cable", 5, 1439);
            pdb.append("2-3399-6693-2m", "halogen bulb h4 55/60w", 22, 813);
            pdb.append("2-599-2029-6k", "turbo oil line o-ring ", 26, 155);
            pdb.append("3-1299-3299-9u", "air pump electric", 9, 20200);
         }
         dumpRecords(pdb);
         pdb.update(1, "1-3233-44923-7j", "parking brake cable", 5, 1995);
         dumpRecords(pdb);
//         throw new IOException("i/o error");
      }
      catch (IOException ioe)
      {
         System.err.println(ioe);
**         if (ioe.getSuppressed().length == 1)**
**            System.err.println("suppressed = "+ioe.getSuppressed()[0]);**
      }
   }
   static void dumpRecords(PartsDB pdb) throws IOException
   {
      for (int i = 0; i < pdb.numRecs(); i++)
      {
         PartsDB.Part part = pdb.select(i);
         System.out.print(format(part.getPartnum(), PartsDB.PNUMLEN, true));
         System.out.print(" | ");
         System.out.print(format(part.getDesc(), PartsDB.DESCLEN, true));
         System.out.print(" | ");
         System.out.print(format(""+part.getQty(), 10, false));
         System.out.print(" | ");
         String s = part.getUnitCost()/100+"."+part.getUnitCost()%100;
         if (s.charAt(s.length()-2) == '.') s += "0";
         System.out.println(format(s, 10, false));
      }
      System.out.println("number of records = "+pdb.numRecs());
      System.out.println();
   }
   static String format(String value, int maxWidth, boolean leftAlign)
   {
      StringBuffer sb = new StringBuffer();
      int len = value.length();
      if (len > maxWidth)
      {
         len = maxWidth;
         value = value.substring(0, len);
      }
      if (leftAlign)       {
         sb.append(value);
         for (int i = 0; i < maxWidth-len; i++)
            sb.append(" ");
      }
      else
      {
         for (int i = 0; i < maxWidth-len; i++)
            sb.append(" ");
         sb.append(value);
      }
      return sb.toString();
   }
}`

清单 8-7 的main()方法首先实例化PartsDB,用parts.db作为数据库文件的名称。当该文件没有记录时,numRecs()返回 0,通过append()方法将几条记录追加到文件中。

main()接下来将存储在parts.db中的五条记录转储到标准输出设备,更新编号为 1 的记录中的单位成本,再次将这些记录转储到标准输出设备以显示这一变化,并关闭数据库。

images 注意我将单位成本值存储为基于整数的便士数量。例如,我指定 literal 1995来表示 1995 年的便士,即 19.95 美元。如果我要使用java.math.BigDecimal对象来存储货币值,我将不得不重构PartsDB来利用对象序列化,但我现在还不准备这么做。(我将在本章后面讨论对象序列化。)

main()依靠一个dumpRecords()助手方法来转储这些记录,dumpRecords()依靠一个format()助手方法来格式化字段值,以便它们可以显示在正确对齐的列中。以下输出揭示了这种一致性:

1-9009-3323-4x       | Wiper Blade Micro Edge         |         30 |      24.68
1-3233-44923-7j      | Parking Brake Cable            |          5 |      **14.39**
2-3399-6693-2m       | Halogen Bulb H4 55/60W         |         22 |       8.13
2-599-2029-6k        | Turbo Oil Line O-Ring          |         26 |       1.55
3-1299-3299-9u       | Air Pump Electric              |          9 |     202.00
Number of records = 5

1-9009-3323-4x       | Wiper Blade Micro Edge         |         30 |      24.68
1-3233-44923-7j      | Parking Brake Cable            |          5 |      **19.95**
2-3399-6693-2m       | Halogen Bulb H4 55/60W         |         22 |       8.13
2-599-2029-6k        | Turbo Oil Line O-Ring          |         26 |       1.55
3-1299-3299-9u       | Air Pump Electric              |          9 |     202.00
Number of records = 5

清单 8-7 依靠 try-with-resources 语句来简化代码——注意try (PartsDB pdb = new PartsDB("parts.db"))。要观察一个被抑制的异常,取消对清单 8-6 的close()方法中 的 throw 语句的注释(确保注释掉该方法中的raf.close();,否则编译器会报错无法到达的代码),取消对清单 8-7 的 try 块中的 throw 语句的注释。这一次,当您运行应用时,您会注意到输出的末尾有下面两行:

java.io.IOException: I/O error
Suppressed = java.io.IOException: cannot close raf

这就是:一个简单的平面文件数据库。尽管缺乏对高级数据库特性(如事务管理)的支持,平面文件数据库可能就是您的应用所需要的全部。

images 要了解更多关于平面文件数据库的信息,请查看维基百科的“平面文件数据库”条目([en.wikipedia.org/wiki/Flat_file_database](http://en.wikipedia.org/wiki/Flat_file_database))。

溪流

FileRandomAccessFile一起,Java 使用流来执行 I/O 操作。一个是一个任意长度的有序字节序列。字节通过输出流从应用流向目的地,并通过输入流从源流向应用。图 8-2 说明了这些流程。

images

图 8-2。将输出和输入流概念化为流动。

images Java 对的使用,类比说“水流”“电子流”等等。

Java 识别各种流目的地;比如字节数组、文件、屏幕、套接字(网络端点)。Java 也能识别各种流源。例子包括字节数组、文件、键盘和套接字。(我在第九章中讨论插座。)

流类概述

java.io包提供了几个输出流和输入流类,它们是抽象的OutputStreamInputStream类的后代。图 8-3 揭示了输出流类的层次结构。

images

图 8-3。除了PrintStream之外的所有输出流类都由它们的OutputStream后缀表示。

图 8-4 揭示了输入流类的层次结构。

images

图 8-4LineNumberInputStream``StringBufferInputStream已弃用。

LineNumberInputStreamStringBufferInputStream已经被弃用,因为它们不支持不同的字符编码,这是我在本章后面讨论的主题。LineNumberReaderStringReader是他们的替代品。(我将在本章后面讨论读者。)

images 注意 PrintStream是另一个不推荐使用的类,因为它不支持不同的字符编码;PrintWriter是它的替代品。然而,Oracle 是否会弃用这个类值得怀疑,因为 PrintStreamSystem类的outerr类字段的类型;太多的遗留代码依赖于这个事实。

其他 Java 包提供了额外的输出流和输入流类。例如,java.util.zip提供了五个将未压缩数据压缩成各种格式的输出流类,以及五个从相同格式解压缩压缩数据的匹配输入流类:

  • CheckedOutputStream
  • CheckedInputStream
  • DeflaterOutputStream
  • DeflaterInputStream
  • GZIPOutputStream
  • GZIPInputStream
  • InflaterOutputStream
  • InflaterInputStream
  • ZipOutputStream
  • ZipInputStream

为了简洁起见,在这一章中我只关注OutputStreamInputStreamFileOutputStreamFileInputStreamFilterOutputStreamFilterInputStreamBufferedOutputStreamBufferedInputStreamDataOutputStreamDataInputStreamObjectOutputStreamObjectInputStreamPrintStream类。附录 C 讨论了附加的流类。

输出流和输入流

Java 提供了用于执行流 I/O 的OutputStreamInputStream类。OutputStream是所有输出流子类的超类。表 8-7 描述了OutputStream的方法。

images

images

在需要经常保存更改的长时间运行的应用中,flush()方法很有用;例如,前面提到的文本编辑器应用每隔几分钟就将更改保存到一个临时文件中。记住flush()只向平台刷新字节;这样做不一定会导致平台将这些字节刷新到磁盘。

images 注意close()方法自动刷新输出流。当应用在调用close()之前结束时,输出流会自动关闭,其数据会被刷新。

InputStream是所有输入流子类的超类。表 8-8 描述了InputStream的方法。

images

images

images

InputStream子类如ByteArrayInputStream支持通过mark()方法标记输入流中的当前读取位置,稍后通过reset()方法返回到该位置。

images 注意不要忘记调用markSupported()来查明流子类是否支持mark()reset()

FileOutputStream 和 FileInputStream

文件是常见的流目的地和源。具体的FileOutputStream类允许你将一个字节流写到一个文件中;具体的FileInputStream类让你从文件中读取一个字节流。

FileOutputStream子类化OutputStream并声明五个用于创建文件输出流的构造函数。例如,FileOutputStream(String name)为由name标识的现有文件创建一个文件输出流。当文件不存在并且不能被创建,它是一个目录而不是一个普通的文件,或者文件不能被打开输出时,这个构造函数抛出FileNotFoundException

以下示例使用FileOutputStream(String name)创建一个以employee.dat为目的地的文件输出流:

FileOutputStream fos = new FileOutputStream("employee.dat");

images 提示 FileOutputStream(String name)覆盖现有文件。要追加数据而不是覆盖现有内容,调用一个包含一个boolean append参数的FileOutputStream构造函数,并将true传递给这个参数。

FileInputStream子类化InputStream并声明三个用于创建文件输入流的构造函数。例如,FileInputStream(String name)从由name标识的现有文件创建一个文件输入流。当文件不存在,它是一个目录而不是一个普通文件,或者有其他原因导致文件无法打开输入时,这个构造函数抛出FileNotFoundException

以下示例使用FileInputStream(String name)创建一个以employee.dat为源的文件输入流:

FileInputStream fis = new FileInputStream("employee.dat");

清单 8-8 将源代码呈现给一个DumpFileInHex应用,该应用使用FileOutputStreamFileInputStream创建一个包含另一个文件的十六进制表示的文件。

清单 8-8。创建文件的十六进制表示

`import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

class DumpFileInHex
{
   final static String LINE_SEPARATOR = System.getProperty("line.separator");
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java DumpFileInHex pathname");
         return;
      }
      String dest = args[0]+".hex";
      try (FileInputStream fis = new FileInputStream(args[0]);
           FileOutputStream fos = new FileOutputStream(dest))
      {
         StringBuffer sb = new StringBuffer();
         int offset = 0;
         int ch;
         while ((ch = fis.read()) != -1)
         {
            if ((offset%16) == 0)
            {
               writeStr(fos, toHexStr(offset, 8));
               fos.write(' ');
            }
            writeStr(fos, toHexStr(ch, 2));
            fos.write(' ');
            if (ch < 32 || ch > 127)
               sb.append('.');
            else
               sb.append((char) ch);
            if ((++offset%16) == 0)
            {
               writeStr(fos, sb.toString()+LINE_SEPARATOR);
               sb.setLength(0);
            }
         }
         if (sb.length() != 0)
         {
            for (int i = 0; i < 16-sb.length(); i++)
               writeStr(fos, "   ");
            writeStr(fos, sb.toString()+LINE_SEPARATOR);          }
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: "+ioe.getMessage());
      }
   }
   static String toHexStr(int value, int fieldWidth)
   {
      StringBuffer sb = new StringBuffer(Integer.toHexString(value));
      sb.reverse();
      int len = sb.length();
      for (int i = 0; i < fieldWidth-len; i++)
         sb.append('0');
      sb.reverse();
      return sb.toString();
   }
   static void writeStr(FileOutputStream fos, String s) throws IOException
   {
      for (int i = 0; i < s.length(); i++)
         fos.write(s.charAt(i));
   }
}`

清单 8-8 的DumpFileInHex类首先声明一个包含line.separator系统属性的值的LINE_SEPARATOR常量。输出该常量的值以结束当前文本行并开始新的文本行。因为不同的平台提供了不同的行分隔符(例如,Unix/Linux 上的换行符或 Windows 上的回车后跟换行符),所以输出LINE_SEPARATOR确保了最大的可移植性。

DumpFileInHex next 给出了它的main()方法,它的第一个任务是确保只指定了一个命令行参数(标识输入文件)。假设是这种情况,main()接下来通过将.hex附加到命令行参数的值来创建输出文件的名称。

接下来,main()给出了一个 try-with-resources 语句,该语句最初打开输入文件并创建输出文件。然后,在toHexStr()writeStr()方法的帮助下,try 块使用 while 循环从输入文件中读取每个字节,并将该字节的十六进制表示和文字值写入输出文件:

  • toHexStr()确保在十六进制值字符串前添加前导零,以适应字段宽度。例如,如果一个十六进制值必须正好占据八个字段位置,并且如果它的长度小于 8,则在字符串前面加上前导 0。(虽然 Java 提供了java.util.Formatter类来处理这个任务,但是toHexStr()现在已经足够了,因为我直到附录 c 才讨论Formatter)
  • writeStr()将一串 8 位字符写入文件输出流。通常情况下,你不会创建这样的方法,因为它忽略了不同的字符集(本章后面会讨论)。然而,字符集不是这个例子的问题。

编译完这个清单(javac DumpFileInHex.java)后,假设您想要创建一个结果DumpFileInHex.class文件的十六进制表示。你可以通过执行java DumpFileInHex DumpFileInHex.class来完成这个任务。如果一切顺利,这个命令行会创建一个DumpFileInHex.class.hex文件。该文件的第一部分如下所示:

00000000 ca fe ba be 00 00 00 33 00 88 0a 00 29 00 42 09 .......3....).B. 00000010 00 43 00 44 08 00 45 0a 00 46 00 47 07 00 48 0a .C.D..E..F.G..H. 00000020 00 05 00 42 0a 00 05 00 49 08 00 4a 0a 00 05 00 ...B....I..J.... 00000030 4b 07 00 4c 0a 00 0a 00 4d 07 00 4e 0a 00 0c 00 K..L....M..N.... 00000040 4d 07 00 4f 0a 00 0e 00 42 0a 00 0a 00 50 0a 00 M..O....B....P.. 00000050 28 00 51 0a 00 28 00 52 0a 00 0c 00 53 0a 00 0e (.Q..(.R....S... 00000060 00 54 0a 00 0e 00 4b 09 00 28 00 55 0a 00 0e 00 .T....K..(.U.... 00000070 56 0a 00 0e 00 57 08 00 58 0a 00 0c 00 59 07 00 V....W..X....Y.. 00000080 5a 0a 00 1b 00 5b 0a 00 0a 00 59 07 00 5c 08 00 Z....[....Y..\.. 00000090 5d 0a 00 1e 00 5e 0a 00 5f 00 60 0a 00 0e 00 4d ]....^.._.....M
000000a0 0a 00 0e 00 61 0a 00 62 00 57 0a 00 62 00 63 08 ....a..b.W..b.c.
000000b0 00 64 0a 00 43 00 65 07 00 66 07 00 67 01 00 0e .d..C.e..f..g...
000000c0 4c 49 4e 45 5f 53 45 50 41 52 41 54 4f 52 01 00 LINE_SEPARATOR..
000000d0 12 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 .Ljava/lang/Stri
000000e0 6e 67 3b 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 ng;......(
000000f0 29 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 )V...Code...Line`

滤波器 utputStream 和 FilterInputStream

文件流将字节原封不动地传递到目的地。Java 还支持过滤流,在输入流到达目的地之前对其进行缓冲、压缩/解压缩、加密/解密或其他操作。

过滤器输出流获取传递给其write()方法的数据(输入流),对其进行过滤,并将过滤后的数据写入底层输出流,该输出流可能是另一个过滤器输出流或目标输出流,如文件输出流。

过滤器输出流是从 concrete FilterOutputStream类的子类创建的,concreteFilterOutputStream类是一个OutputStream子类。FilterOutputStream声明了一个单独的FilterOutputStream(OutputStream out)构造函数,它创建了一个构建在out之上的过滤器输出流,即底层输出流。

清单 8-9 揭示了子类化FilterOutputStream很容易。至少,声明一个构造函数,将它的OutputStream参数传递给FilterOutputStream的构造函数,并覆盖FilterOutputStreamvoid write(int b)方法。

清单 8-9。加扰一个字节流

`import java.io.FilterOutputStream;
import java.io.IOException;
import java.io.OutputStream;

class ScrambledOutputStream extends FilterOutputStream
{
   private int[] map;
   ScrambledOutputStream(OutputStream out, int[] map)
   {
      super(out);
      if (map == null)
         throw new NullPointerException("map is null");
      if (map.length != 256)
         throw new IllegalArgumentException("map.length != 256");       this.map = map;
   }
   @Override
   public void write(int b) throws IOException
   {
      out.write(map[b]);
   }
}`

清单 8-9 展示了一个ScrambledOutputStream类,它通过重新映射操作对输入流的字节进行加密,从而对输入流执行简单的加密。它的构造函数接受一对参数:

  • out标识要写入加扰字节的输出流。
  • map标识输入流字节映射到的 256 字节整数值的数组。

构造函数首先通过一个super(out)调用将其out参数传递给FilterOutputStream父节点。然后,在保存map之前,它验证其map参数的完整性(map必须非空,长度为 256——一个字节流正好提供 256 个字节进行映射)。

write()方法很简单:它用参数b映射到的字节调用底层输出流的write()方法。FilterOutputStream声明outprotected(为了性能),这就是为什么我可以直接访问这个字段。

images 注意只需重写write(int),因为FilterOutputStream的另外两个write()方法都是根据这个方法实现的。

清单 8-10 展示了一个Scramble应用的源代码,用于通过ScrambledOutputStream对源文件的字节进行加扰,并将这些加扰后的字节写入目标文件。

清单 8-10。打乱一个文件的字节

`import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

import java.util.Random;

class Scramble
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java Scramble srcpath destpath");
         return;
      }
      try (FileInputStream fis = new FileInputStream(args[0]);            ScrambledOutputStream sos =
              new ScrambledOutputStream(new FileOutputStream(args[1]),
                                        makeMap()))
      {
         int b;
         while ((b = fis.read()) != -1)
            sos.write(b);
      }
      catch (IOException ioe)
      {
         ioe.printStackTrace();
      }
   }
   static int[] makeMap()
   {
      int[] map = new int[256];
      for (int i = 0; i < map.length; i++)
         map[i] = i;
      // Shuffle map.
      Random r = new Random(0);
      for (int i = 0; i < map.length; i++)
      {
         int n = r.nextInt(map.length);
         int temp = map[i];
         map[i] = map[n];
         map[n] = temp;
      }
      return map;
   }
}`

Scramblemain()方法首先验证命令行参数的个数:第一个参数标识包含未解读内容的文件的源路径;第二个参数标识存储加密内容的文件的目标路径。

假设已经指定了两个命令行参数,main()实例化了FileInputStream,创建了一个连接到由args[0]标识的文件的文件输入流。

继续,main()实例化FileOutputStream,创建一个连接到由args[1]标识的文件的文件输出流。然后实例化ScrambledOutputStream,将FileOutputStream实例传递给ScrambledOutputStream的构造函数。

images 注意当一个流实例被传递给另一个流类的构造函数时,两个流被链接在一起。例如,加扰输出流链接到文件输出流。

main()现在进入一个循环,通过调用ScrambledOutputStreamvoid write(int b)方法,从文件输入流中读取字节,并将它们写入加扰的输出流。这个循环一直持续到FileInputStreamint read()方法返回-1(文件结束)。

try-with-resources 语句通过调用它们的close()方法来关闭文件输入流和加扰输出流。它不调用文件输出流的close()方法,因为FilterOutputStream自动调用底层输出流的close()方法。

makeMap()方法负责创建传递给ScrambledOutputStream的构造函数的 map 数组。想法是用所有 256 字节的整数值填充数组,以随机顺序存储它们。

images 注意我在创建java.util.Random对象时传递 0 作为种子参数,以便返回一个可预测的随机数序列。在Unscramble应用中创建互补的 map 数组时,我需要使用相同的随机数序列,我将很快介绍这一点。没有相同的序列,解读将无法工作。

假设您有一个简单的名为hello.txt的 15 字节文件,其中包含“Hello, World!”(后跟回车符和换行符)。当你在 XP 平台上执行java Scramble hello.txt hello.out时,你会观察到图 8-5 的混乱输出。

images

图 8-5。不同的字体产生不同外观的乱码输出。

过滤器输入流从其底层输入流(可能是另一个过滤器输入流或源输入流,如文件输入流)获取数据,对其进行过滤,并通过其read()方法(输出流)使该数据可用。

过滤器输入流是从 concrete FilterInputStream类的子类创建的,concreteFilterInputStream类是一个InputStream子类。FilterInputStream声明了一个单独的FilterInputStream(InputStream in)构造函数,它创建了一个构建在in之上的过滤器输入流,即底层输入流。

清单 8-11 显示子类化FilterInputStream很容易。至少,声明一个构造函数,将它的InputStream参数传递给FilterInputStream的构造函数,并覆盖FilterInputStreamint read()int read(byte[] b, int off, int len)方法。

清单 8-11。解读字节流

`import java.io.FilterInputStream;
import java.io.InputStream;
import java.io.IOException;

class ScrambledInputStream extends FilterInputStream
{
   private int[] map;
   ScrambledInputStream(InputStream in, int[] map)
   {
      super(in);
      if (map == null)          throw new NullPointerException("map is null");
      if (map.length != 256)
         throw new IllegalArgumentException("map.length != 256");
      this.map = map;
   }
   @Override
   public int read() throws IOException
   {
      int value = in.read();
      return (value == -1) ? -1 : map[value];
   }
   @Override
   public int read(byte[] b, int off, int len) throws IOException
   {
      int nBytes = in.read(b, off, len);
      if (nBytes <= 0)
         return nBytes;
      for (int i = 0; i < nBytes; i++)
         b[off+i] = (byte) map[off+i];
      return nBytes;
   }
}`

清单 8-11 展示了一个ScrambledInputStream类,它通过重新映射操作对底层输入流的加扰字节进行解扰,从而对底层输入流执行简单的解密。

read()方法首先从底层输入流中读取加扰的字节。如果返回值为-1(文件结束),则将该值返回给调用者。否则,该字节将被映射到其未加扰的值,该值将被返回。

read(byte[], int, int)方法类似于read(),但是将从底层输入流中读取的字节存储在一个字节数组中,并考虑到数组中的偏移量和长度(要读取的字节数)。

同样,底层的read()方法调用可能会返回-1。如果是,则必须返回该值。否则,数组中的每个字节都被映射到它的未加扰值,并返回读取的字节数。

images 注意只需重写read()read(byte[], int, int)即可,因为FilterInputStreamint read(byte[] b)方法是通过后一种方法实现的。

清单 8-12 将源代码呈现给一个UnScramble应用,通过对源文件的字节进行解扰并将这些解扰的字节写入一个目标文件来试验ScrambledInputStream

清单 8-12。解读文件的字节

import java.io.FileInputStream; import java.io.FileOutputStream; `import java.io.IOException;

import java.util.Random;

class Unscramble
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java unscramble srcpath destpath");
         return;
      }
      try (FileOutputStream fos = new FileOutputStream(args[1]);
           ScrambledInputStream sis =
              new ScrambledInputStream(new FileInputStream(args[0]),
                                       makeMap()))

{
         int b;
         while ((b = sis.read()) != -1)
            fos.write(b);
      }
      catch (IOException ioe)
      {
         ioe.printStackTrace();
      }
   }
   static int[] makeMap()
   {
      int[] map = new int[256];
      for (int i = 0; i < map.length; i++)
         map[i] = i;
      // Shuffle map.
      Random r = new Random(0);
      for (int i = 0; i < map.length; i++)
      {
         int n = r.nextInt(map.length);
         int temp = map[i];
         map[i] = map[n];
         map[n] = temp;
      }
      int[] temp = new int[256];
      for (int i = 0; i < temp.length; i++)
         temp[map[i]] = i;
      return temp;
   }
}`

Unscramblemain()方法首先验证命令行参数的个数:第一个参数标识带有加扰内容的文件的源路径;第二个参数标识存储未加扰内容的文件的目标路径。

假设已经指定了两个命令行参数,main()实例化FileOutputStream,创建一个文件输出流,该流连接到由args[1]标识的文件。

继续,main()实例化FileInputStream,创建一个连接到由args[0]标识的文件的文件输入流。然后实例化ScrambledInputStream,将FileInputStream实例传递给ScrambledInputStream的构造函数。

images 注意当一个流实例被传递给另一个流类的构造函数时,两个流被链接在一起。例如,加扰的输入流链接到文件输入流。

main()现在进入一个循环,从加扰的输入流中读取字节,并将它们写入文件输出流。这个循环一直持续到ScrambledInputStreamread()方法返回-1(文件结束)。

try-with-resources 语句通过调用它们的close()方法来关闭文件输出流和加密的输入流。它不调用文件输入流的close()方法,因为FilterInputStream自动调用底层输入流的close()方法。

makeMap()方法负责创建传递给ScrambledInputStream的构造函数的 map 数组。这个想法是复制清单 8-10 的地图数组,然后将其反转,以便可以执行解读。

继续前面的hello.txt / hello.out示例,执行java Unscramble hello.out hello.bak,您将在hello.bak中看到与hello.txt中相同的未解码内容。

buffer utputstream 和 bufferedinputstream

FileOutputStreamFileInputStream出现性能问题。每个文件输出流write()方法调用和文件输入流read()方法调用都会导致对底层平台的本地方法之一的调用,这些本地方法调用会降低 I/O 的速度(我将在附录 c 中讨论本地方法)

具体的BufferedOutputStreamBufferedInputStream过滤器流类通过最小化底层输出流write()和底层输入流read()方法调用来提高性能。相反,对BufferedOutputStreamwrite()BufferedInputStreamread()方法的调用考虑了 Java 缓冲区:

  • 当写缓冲区已满时,write()调用底层输出流write()方法来清空缓冲区。对BufferedOutputStreamwrite()方法的后续调用将字节存储在这个缓冲区中,直到它再次充满。
  • 当读缓冲区为空时,read()调用底层输入流read()方法来填充缓冲区。对BufferedInputStreamread()方法的后续调用从这个缓冲区返回字节,直到它再次变空。

BufferedOutputStream声明了以下构造函数:

  • BufferedOutputStream(OutputStream out)创建一个缓冲输出流,将其输出传输到out。创建一个内部缓冲区来存储写入out的字节。
  • BufferedOutputStream(OutputStream out, int size)创建一个缓冲输出流,将其输出传输到out。创建一个长度为size的内部缓冲区来存储写入out的字节。

下面的例子将一个BufferedOutputStream实例链接到一个FileOutputStream实例。后续的write()方法调用在BufferedOutputStream实例缓冲字节上,偶尔会导致内部write()方法调用在封装的FileOutputStream实例上:

FileOutputStream fos = new FileOutputStream("employee.dat");
BufferedOutputStream bos = new BufferedOutputStream(fos); // Chain bos to fos.
bos.write(0); // Write to employee.dat through the buffer.
// Additional write() method calls.
bos.close(); // This method call internally calls fos's close() method.

BufferedInputStream声明了以下构造函数:

  • BufferedInputStream(InputStream in)创建一个缓冲输入流,从in输入。创建一个内部缓冲区来存储从in读取的字节。
  • BufferedInputStream(InputStream in, int size)创建一个缓冲输入流,从in输入。创建一个长度为size的内部缓冲区来存储从in读取的字节。

以下示例将一个BufferedInputStream实例链接到一个FileInputStream实例。后续的read()方法调用在BufferedInputStream实例上解缓冲字节,偶尔会导致内部的read()方法调用在封装的FileInputStream实例上:

FileInputStream fis = new FileInputStream("employee.dat");
BufferedInputStream bis = new BufferedInputStream(fis); // Chain bis to fis.
int ch = bis.read(); // Read employee.dat through the buffer.
// Additional read() method calls.
bis.close(); // This method call internally calls fis's close() method.

DataOutputStream 和 DataInputStream

FileOutputStreamFileInputStream对于读写字节和字节数组很有用。但是,它们不支持读写基本类型值(如整数)和字符串。

为此,Java 提供了具体的DataOutputStreamDataInputStream过滤流类。每个类都通过提供以独立于平台的方式写入或读取基元类型值和字符串的方法来克服这一限制:

  • 整数值以大端格式读写(最高有效字节在前)。查看维基百科的“字节序”条目([en.wikipedia.org/wiki/Endianness](http://en.wikipedia.org/wiki/Endianness)),了解字节序的概念。
  • 浮点和双精度浮点值是根据 IEEE 754 标准读写的,该标准规定每个浮点值四个字节,每个双精度浮点值八个字节。
  • 字符串是根据修改后的 UTF-8 来读写的,这是一种有效存储双字节 Unicode 字符的可变长度编码标准。查看维基百科的“UTF-8”条目([en.wikipedia.org/wiki/Utf-8](http://en.wikipedia.org/wiki/Utf-8)),了解更多关于 UTF-8 的信息。

DataOutputStream声明一个DataOutputStream(OutputStream out)构造函数。因为这个类实现了DataOutput接口,DataOutputStream还提供了对RandomAccessFile提供的同名写方法的访问。

DataInputStream声明一个单独的DataInputStream(InputStream in)构造函数。因为这个类实现了DataInput接口,DataInputStream还提供了对RandomAccessFile提供的同名读取方法的访问。

清单 8-13 给出了一个DataStreamsDemo应用的源代码,该应用使用一个DataOutputStream实例将多字节值写入一个FileOutputStream实例,并使用DataInputStream从一个FileInputStream实例读取多字节值。

清单 8-13。输出然后输入多字节值流

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;

class DataStreamsDemo
{
   final static String FILENAME = "values.dat";
   public static void main(String[] args)
   {
      try (DataOutputStream dos =
              new DataOutputStream(new FileOutputStream(FILENAME)))
      {
         dos.writeInt(1995);
         dos.writeUTF("saving this string in modified utf-8 format!");
         dos.writeFloat(1.0F);
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
      try (DataInputStream dis =
              new DataInputStream(new FileInputStream(FILENAME)))
      {
         System.out.println(dis.readInt());
         System.out.println(dis.readUTF());
         System.out.println(dis.readFloat());
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}

DataStreamsDemo创建一个名为values.dat的文件,调用DataOutputStream方法向该文件写入一个整数、一个字符串和一个浮点值,并调用DataInputStream方法读回这些值。不出所料,它会生成以下输出:

1995
Saving this String in modified UTF-8 format!
1.0

images 注意当读取由DataOutputStream方法调用序列写入的值文件时,确保使用相同的方法调用序列。否则,您肯定会得到错误的数据,并且在使用readUTF()方法的情况下,会抛出java.io.UTFDataFormatException类的实例(是IOException的子类)。

对象序列化和反序列化

Java 提供了DataOutputStreamDataInputStream类来传输原始类型值和String对象。但是,您不能使用这些类来流式传输非String对象。相反,您必须使用对象序列化和反序列化来流式传输任意类型的对象。

对象序列化是一个 JVM 机制,用于对象状态序列化为字节流。它的反序列化对应部分是一个 JVM 机制,用于从字节流中反序列化该状态。

images 注意一个对象的状态由存储原始类型值和/或对其他对象的引用的实例字段组成。当对象被序列化时,属于该状态的对象也被序列化(除非您阻止它们被序列化),它们的对象被序列化(除非被阻止),依此类推。

Java 支持三种形式的序列化和反序列化:默认序列化和反序列化、自定义序列化和反序列化以及外部化。

默认序列化和反序列化

默认的序列化和反序列化是最容易使用的形式,但是对如何序列化和反序列化对象几乎没有控制。尽管 Java 代表您处理了大部分工作,但是有几项任务您必须执行。

您的第一个任务是让要序列化的对象的类实现java.io.Serializable接口(直接或通过类的超类间接实现)。实现Serializable的基本原理是为了避免无限制的序列化。

images 注意 Serializable是一个空的标记接口(没有要实现的方法),类实现它来告诉 JVM 可以序列化类的对象。当序列化机制遇到一个其类没有实现Serializable的对象时,它抛出一个java.io.NotSerializableException类的实例(一个IOException的间接子类)。

无限制序列化是序列化整个对象图的过程(从一个起始对象可到达的所有对象)。Java 不支持无限制的序列化,原因如下:

  • 安全:如果 Java 自动序列化一个包含敏感信息(比如密码或信用卡号)的对象,黑客很容易发现这些信息并大肆破坏。最好给开发者一个选择,防止这种情况发生。
  • 性能:序列化利用了反射 API,我在第四章中介绍过。在那一章中,你学到了反射会降低应用的性能。无限制的序列化真的会损害应用的性能。
  • 不适合序列化的对象:有些对象只存在于正在运行的应用的上下文中,序列化它们是没有意义的。例如,反序列化的文件流对象不再表示与文件的连接。

清单 8-14 声明了一个Employee类,它实现了Serializable接口来告诉 JVM 可以序列化Employee对象。

清单 8-14。实施Serializable

class Employee implements java.io.Serializable
{
   private String name;
   private int age;
   Employee(String name, int age)
   {
      this.name = name;
      this.age = age;
   }
   String getName() { return name; }
   int getAge() { return age; }
}

因为Employee实现了Serializable,序列化一个Employee对象时序列化机制不会抛出NotSerializableException。不仅Employee实现了Serializable,而且String类也实现了这个接口。

你的第二个任务是使用ObjectOutputStream类及其void writeObject(Object obj)方法来序列化一个对象,使用OutputInputStream类及其Object readObject()方法来反序列化该对象。

images 注意虽然ObjectOutputStream扩展了OutputStream而不是FilterOutputStream,虽然ObjectInputStream扩展了InputStream而不是FilterInputStream,但是这些类的行为就像过滤流一样。

Java 提供了具体的ObjectOutputStream类来启动对象状态到对象输出流的序列化。这个类声明了一个ObjectOutputStream(OutputStream out)构造器,它将对象输出流链接到由out指定的输出流。

当您将一个输出流引用传递给out时,该构造函数会尝试将一个序列化头写入该输出流。当outnull时,它抛出NullPointerException,当一个 I/O 错误阻止它写这个头时,它抛出IOException

ObjectOutputStream通过对象的writeObject()方法序列化对象。这个方法试图将关于obj的类的信息,后跟obj的实例字段的值写入底层输出流。

writeObject()不序列化static字段的内容。相比之下,它序列化所有没有明确以transient保留字为前缀的实例字段的内容。例如,考虑以下字段声明:

public transient char[] password;

这个声明指定了transient来避免序列化一个密码给一些黑客。JVM 的序列化机制忽略任何标记为transient的实例字段。

当出错时,writeObject()抛出IOException或者一个IOException子类的实例。例如,这个方法在遇到一个类没有实现Serializable的对象时抛出NotSerializableException

images 注意因为ObjectOutputStream实现了DataOutput,所以它还声明了将原语类型值和字符串写入对象输出流的方法。

Java 提供了具体的ObjectInputStream类来启动对象输入流中对象状态的反序列化。这个类声明了一个ObjectInputStream(InputStream in)构造器,它将对象输入流链接到由in指定的输入流。

当您将一个输入流引用传递给in时,该构造函数试图从该输入流中读取一个序列化头。当innull时,它抛出NullPointerException,当一个 I/O 错误阻止它读取这个头时抛出IOException,当流头不正确时抛出java.io.StreamCorruptedException(一个IOException的间接子类)。

ObjectInputStream通过对象的readObject()方法反序列化对象。这个方法试图从底层输入流中读取关于obj的类的信息,后跟obj的实例字段的值。

当出错时,readObject()抛出java.lang.ClassNotFoundExceptionIOExceptionIOException子类的实例。例如,这个方法在遇到原始值而不是对象时抛出java.io.OptionalDataException

images 注意因为ObjectInputStream实现了DataInput,所以它还声明了从对象输入流中读取原始类型值和字符串的方法。

清单 8-15 展示了一个应用,它使用这些类来序列化和反序列化清单 8-14 的Employee类的一个实例到一个employee.dat文件。

清单 8-15。序列化和反序列化一个Employee对象

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

class SerializationDemo
{
   final static String FILENAME = "employee.dat";
   public static void main(String[] args)
   {
      try (ObjectOutputStream oos =
              new ObjectOutputStream(new FileOutputStream(FILENAME)))
      {
         Employee emp = new Employee("john doe", 36);
         oos.writeObject(emp);
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
         return;
      }
      try (ObjectInputStream ois =
              new ObjectInputStream(new FileInputStream(FILENAME)))
      {
         Employee emp = (Employee) ois.readObject();
         System.out.println(emp.getName());
         System.out.println(emp.getAge());
      }
      catch (ClassNotFoundException cnfe)
      {
         System.err.println(cnfe.getMessage());
      }
      catch (IOException ioe)
      {
         System.err.println(ioe.getMessage());
      }
   }
}

清单 8-15 的main()方法首先实例化Employee,并通过writeObject()将该实例序列化为employee.dat。然后它通过readObject()从这个文件中反序列化这个实例,并调用实例的getName()getAge()方法。与employee.dat一起,当您运行这个应用时,您会发现下面的输出:

John Doe
36

当序列化对象被反序列化时,不能保证相同的类会存在(可能实例字段已经被删除)。在反序列化过程中,当检测到被反序列化的对象和它的类之间的差异时,这个机制导致readObject()抛出一个java.io.InvalidClassException(IOException的间接子类)的实例。

每个序列化对象都有一个标识符。反序列化机制将被反序列化的对象的标识符与其类的序列化标识符进行比较(所有可序列化的类都被自动赋予唯一的标识符,除非它们显式指定了自己的标识符),并在检测到不匹配时引发InvalidClassException

也许您已经向类中添加了一个实例字段,并且希望反序列化机制将实例字段设置为默认值,而不是让readObject()抛出一个InvalidClassException实例。(下次序列化对象时,将写出新字段的值。)

您可以通过向类添加一个static final long serialVersionUID = *long integer value*;声明来避免抛出的InvalidClassException实例。 long integer value 必须是唯一的,被称为流唯一标识符(SUID)

在反序列化过程中,JVM 会将反序列化对象的 SUID 与其类的 SUID 进行比较。如果它们匹配,readObject()在遇到兼容的类变更readObject()不会抛出InvalidClassException(例如,添加实例字段)。然而,当遇到不兼容的类变更(例如,变更实例字段的名称或类型)时,它仍然会抛出这个异常。

images 注意每当你以某种方式改变一个类时,你必须计算一个新的 SUID,并把它赋给serialVersionUID

JDK 为计算 SUID 提供了一个serialver工具。例如,要为清单 8-14 的Employee类生成一个 SUID,切换到包含Employee.class的目录并执行serialver Employee。作为响应,serialver生成以下输出,您将其粘贴(除了Employee:)到Employee.java:

Employee:    static final long serialVersionUID = -6768634186769913248L;

Windows 版本的serialver还提供了一个图形用户界面(GUI ),您可能会发现使用起来更方便。要访问这个 GUI,请指定serialver -show。当 GUI 出现时,在完整类名文本框中输入Employee并点击显示按钮,如图 8-6 中的所示。

images

图 8-6serialverGUI 显示Employee的 SUID。

自定义序列化和反序列化

我之前的讨论集中在默认的序列化和反序列化(除了标记一个实例字段transient以防止它在序列化过程中被包含)。但是,有时您需要定制这些任务。

例如,假设您想要序列化一个没有实现Serializable的类的实例。作为一种变通方法,您将这个类分成子类,让子类实现Serializable,并将子类构造函数调用转发给超类。

尽管这种变通方法允许您序列化子类对象,但是当超类没有声明反序列化机制所要求的无参数构造函数时,您不能反序列化这些序列化的对象。清单 8-16 演示了这个问题。

清单 8-16。有问题的反序列化

`import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class Employee
{
   private String name;
   Employee(String name) { this.name = name; }
   @Override
   public String toString() { return name; }
}
class SerEmployee extends Employee implements Serializable
{
   SerEmployee(String name) { super(name); }
}
class SerializationDemo
{
   public static void main(String[] args)
   {
      try (ObjectOutputStream oos =
              new ObjectOutputStream(new FileOutputStream("employee.dat")))
      {
         SerEmployee se = new SerEmployee("John Doe");
         System.out.println(se);
         oos.writeObject(se);
         System.out.println("se object written to file");
      }
      catch (Exception e)
      {
         e.printStackTrace();
      }
      try (ObjectInputStream ois =
              new ObjectInputStream(new FileInputStream("employee.dat")))
      {
         Object o = ois.readObject();          System.out.println("se object read from byte array");
      }
      catch (Exception e)
      {
         e.printStackTrace();
      }
   }
}`

清单 8-16 的main()方法用雇员姓名实例化SerEmployee。这个类的SerEmployee(String)构造函数将这个参数传递给它的Employee对手。

main() next 通过System.out.println()间接调用EmployeetoString()方法,获取这个名字,然后输出。

接下来,main()通过writeObject()SerEmployee实例序列化为employee.dat文件。然后它试图通过readObject()反序列化这个对象,这就是问题所在,如下图所示:

John Doe
se object written to file
java.io.InvalidClassException: SerEmployee; SerEmployee; no valid constructor
        at java.io.ObjectStreamClass.checkDeserialize(ObjectStreamClass.java:730)
        at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1751)
        at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1347)
        at java.io.ObjectInputStream.readObject(ObjectInputStream.java:369)
        at SerializationDemo.main(SerializationDemo.java:37)
Caused by: java.io.InvalidClassException: SerEmployee; no valid constructor
        at java.io.ObjectStreamClass.<init>(ObjectStreamClass.java:488)
        at java.io.ObjectStreamClass.lookup(ObjectStreamClass.java:327)
        at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1130)
        at java.io.ObjectOutputStream.writeObject(ObjectOutputStream.java:346)
        at SerializationDemo.main(SerializationDemo.java:27)

这个输出揭示了一个抛出的InvalidClassException类的实例。这个异常对象在反序列化过程中被抛出,因为Employee没有无参数构造函数。

我们可以通过利用我在第二章中介绍的包装类模式来解决这个问题。此外,我们在子类中声明了一对私有方法,序列化和反序列化机制会查找并调用它们。

通常,序列化机制将类的实例字段写出到基础输出流中。但是,您可以通过在该类中声明一个私有的void writeObject(ObjectOutputStream oos)方法来防止这种情况发生。

当序列化机制发现此方法时,它会调用方法,而不是自动输出实例字段值。唯一输出的值是通过方法显式输出的值。

相反,反序列化机制向从底层输入流中读取的类的实例字段赋值。然而,您可以通过声明一个私有的void readObject(ObjectInputStream ois)方法来防止这种情况发生。

当反序列化机制发现此方法时,它会调用方法,而不是自动为实例字段赋值。分配给实例字段的唯一值是通过方法显式分配的值。

因为SerEmployee没有引入任何字段,也因为Employee没有提供对其内部字段的访问(假设你没有这个类的源代码),序列化的SerEmployee对象会包含什么呢?

虽然我们不能序列化Employee的内部状态,但是我们可以序列化传递给其构造函数的参数,比如雇员姓名。

清单 8-17 揭示了重构后的SerEmployeeSerializationDemo类。

清单 8-17。解决有问题的反序列化

`import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;

class Employee
{
   private String name;
   Employee(String name) { this.name = name; }
   @Override
   public String toString() { return name; }
}
class SerEmployee implements Serializable
{
   private Employee emp;
   private String name;
   SerEmployee(String name)
   {
      this.name = name;
      emp = new Employee(name);
   }
   private void writeObject(ObjectOutputStream oos) throws IOException
   {
      oos.writeUTF(name);
   }
   private void readObject(ObjectInputStream ois)
      throws ClassNotFoundException, IOException
   {
      name = ois.readUTF();
      emp = new Employee(name);
   }
   @Override
   public String toString()
   {
      return name;
   }
}
class SerializationDemo
{
   public static void main(String[] args)
   {
      try (ObjectOutputStream oos =
              new ObjectOutputStream(new FileOutputStream("employee.dat")))       {
         SerEmployee se = new SerEmployee("John Doe");
         System.out.println(se);
         oos.writeObject(se);
         System.out.println("se object written to file");
      }
      catch (Exception e)
      {
         e.printStackTrace();
      }
      try (ObjectInputStream ois =
              new ObjectInputStream(new FileInputStream("employee.dat")))
      {
         SerEmployee se = (SerEmployee) ois.readObject();
         System.out.println("se object read from file");
         System.out.println(se);
      }
      catch (Exception e)
      {
         e.printStackTrace();
      }
   }
}`

SerEmployeewriteObject()readObject()方法依赖于DataOutputDataInput方法:它们不需要调用writeObject()readObject()来执行任务。

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

John Doe
se object written to file
se object read from file
John Doe

writeObject()readObject()方法可用于序列化/反序列化超出正常状态的数据项(非transient实例字段);例如,序列化/反序列化一个static字段的内容。

但是,在序列化或反序列化附加数据项之前,必须告诉序列化和反序列化机制序列化或反序列化对象的正常状态。以下方法有助于您完成这项任务:

  • ObjectOutputStreamdefaultWriteObject()方法输出对象的正常状态。您的writeObject()方法首先调用这个方法来输出那个状态,然后通过ObjectOutputStream方法如writeUTF()输出额外的数据项。
  • ObjectInputStreamdefaultReadObject()方法输入对象的正常状态。您的readObject()方法首先调用这个方法来输入那个状态,然后通过ObjectInputStream方法如readUTF()输入额外的数据项。
外化

除了默认序列化/反序列化和自定义序列化/反序列化,Java 还支持外部化。与默认/自定义序列化/反序列化不同,外部化提供了对序列化和反序列化任务的完全控制。

images 外部化通过让您完全控制序列化和反序列化哪些字段,帮助您提高基于反射的序列化和反序列化机制的性能。

Java 通过其java.io.Externalizable接口支持外部化。这个接口声明了下面一对public方法:

  • void writeExternal(ObjectOutput out)通过调用out对象上的各种方法保存调用对象的内容。当一个 I/O 错误发生时,这个方法抛出IOException。(java.io.ObjectOutputDataOutput的子接口,由ObjectOutputStream实现。)
  • void readExternal(ObjectInput in)通过调用in对象上的各种方法来恢复调用对象的内容。当发生 I/O 错误时,这个方法抛出IOException,当找不到被恢复对象的类时,抛出ClassNotFoundException。(java.io.ObjectInputDataInput的子接口,由ObjectInputStream实现。)

如果一个类实现了Externalizable,它的writeExternal()方法负责保存所有要保存的字段值。此外,它的readExternal()方法负责恢复所有保存的字段值,并按照它们保存的顺序。

清单 8-18 展示了清单 8-14 的Employee类的重构版本,向您展示如何利用外部化。

清单 8-18。重构清单 8-14 的Employee类以支持外部化

`import java.io.Externalizable;
import java.io.IOException;
import java.io.ObjectInput;
import java.io.ObjectOutput;

class Employee implements Externalizable
{
   private String name;
   private int age;
   public Employee()
   {
      System.out.println("Employee() called");
   }
   Employee(String name, int age)
   {
      this.name = name;       this.age = age;
   }
   String getName() { return name; }
   int getAge() { return age; }
   @Override
   public void readExternal(ObjectInput in)
      throws IOException, ClassNotFoundException
   {
      System.out.println("readExternal() called");
      name = in.readUTF();
      age = in.readInt();
   }
   @Override
   public void writeExternal(ObjectOutput out) throws IOException
   {
      System.out.println("writeExternal() called");
      out.writeUTF(name);
      out.writeInt(age);
   }
}`

Employee声明了一个public Employee()构造函数,因为每个参与外部化的类都必须声明一个public无参数构造函数。反序列化机制调用此构造函数来实例化对象。

images 注意反序列化机制在没有检测到public无参数构造函数时抛出InvalidClassException一条“无有效构造函数”的消息。

通过实例化ObjectOutputStream并调用它的writeObject()方法,或者通过实例化ObjectInputStream并调用它的readObject()方法来启动外部化。

images 注意当传递一个其类(直接/间接)实现ExternalizablewriteObject()的对象时,writeObject()发起的序列化机制只将该对象的类的标识写入对象输出流。

假设你在同一个目录下编译了清单 8-15 的SerializationDemo.java源代码和清单 8-18 的Employee.java源代码。现在假设你处决了java SerializationDemo。作为响应,您会看到以下输出:

writeExternal() called
Employee() called
readExternal() called
John Doe
36

在序列化一个对象之前,序列化机制检查对象的类,看它是否实现了Externalizable。如果是,该机制调用writeExternal()。否则,它会寻找一个私有的writeObject(ObjectOutputStream)方法,如果存在的话就调用这个方法。如果该方法不存在,该机制将执行默认的序列化,它只包括非transient实例字段。

在反序列化一个对象之前,反序列化机制检查对象的类,看它是否实现了Externalizable。如果是这样,该机制会尝试通过公共的无参数构造函数实例化该类。假设成功,它调用readExternal()

如果对象的类没有实现Externalizable,反序列化机制会寻找私有的readObject(ObjectInputStream)方法。如果该方法不存在,该机制将执行默认的反序列化,其中只包括非transient实例字段。

打印流

在所有的流类中,PrintStream是一个古怪的类:为了与命名约定保持一致,它应该被命名为PrintOutputStream。此筛选器输出流类将输入数据项的字符串表示形式写入基础输出流。

images PrintStream使用默认的字符编码将字符串的字符转换成字节。(在下一节向作者和读者介绍字符编码时,我会讨论字符编码。)因为PrintStream不支持不同的字符编码,所以你应该使用等价的PrintWriter类来代替PrintStream。然而,当使用System.outSystem.err时,您需要了解PrintStream,因为这些类字段属于类型PrintStream

PrintStream实例是打印流,其各种print()println()方法将整数、浮点值和其他数据项的字符串表示打印到底层输出流。与print()方法不同,println()方法在输出中添加了一个行结束符。

images 注意行结束符(也称为行分隔符)不一定是换行符(通常也称为换行)。相反,为了提高可移植性,行分隔符是由系统属性line.separator定义的字符序列。在 Windows 平台上,System.getProperty("line.separator")返回实际的回车码(13),用\r象征性地表示,后面是实际的换行符/换行码(10),用\n象征性地表示。相反,System.getProperty("line.separator")在 Unix 和 Linux 平台上只返回实际的换行符/换行符代码。

println()方法调用它们对应的print()方法,然后调用void println()方法的等价方法,最终导致line.separator的值被输出。比如,void println(int x)输出x的字符串表示,调用这个方法输出行分隔符。

images 注意永远不要将\n转义序列硬编码到你要通过print()println()方法输出的字符串中。这样做是不可移植的。例如,当 Java 先执行System.out.print("first line\n");,然后执行System.out.println("second line");时,当在 Windows 命令行查看该输出时,您会在一行看到first line,然后在下一行看到second line。相反,当在 Windows 记事本应用中查看该输出时,您将看到first linesecond line(需要回车/换行序列来终止行)。当你需要输出一个空行的时候,最简单的方法就是调用System.out.println();,这就是为什么你会发现这个方法调用分散在我的书里。我承认我并不总是遵循我自己的建议,所以你可能会在本书的其他地方找到文本字符串中的\n被传递给System.out.print()System.out.println()的实例。

PrintStream提供另外两个有用的功能:

  • 与其他输出流不同,打印流从不重新抛出从底层输出流抛出的IOException实例。相反,异常情况会设置一个内部标志,可以通过调用PrintStreamboolean checkError()方法进行测试,该方法会返回 true 来指示有问题。
  • 可以创建对象来自动将它们的输出刷新到底层输出流。换句话说,在写入一个字节数组、调用一个println()方法或者写入一个换行符之后,会自动调用flush()方法。分配给System.outSystem.errPrintStream实例自动将它们的输出刷新到底层输出流。

作家和读者

Java 的流类适用于字节流序列,但不适用于字符流序列,因为字节和字符是两种不同的东西:一个字节代表一个 8 位数据项,一个字符代表一个 16 位数据项。还有,Java 的charString类型自然处理字符而不是字节。

更重要的是,字节流不知道字符集(整数值【称为码点和符号之间的映射,如 Unicode)和它们的字符编码(字符集成员和字节序列之间的映射,为提高效率对这些字符进行编码,如 UTF-8)。

如果您需要流式传输字符,您应该利用 Java 的 writer 和 reader 类,它们被设计为支持字符 I/O(它们使用char而不是byte)。此外,writer 和 reader 类考虑了字符编码。

字符集和字符编码简史

早期的计算机和编程语言主要是由以英语为母语的国家的英语程序员创造的。他们开发了代码点 0 到 127 与英语中常用的 128 个字符(如 A-Z)之间的标准映射。由此产生的字符集/编码被命名为美国信息交换标准码(ASCII)

ASCII 的问题是它对于大多数非英语语言来说是不够的。例如,ASCII 不支持像法语中使用的 cedilla 这样的变音符号。由于一个字节最多可以表示 256 个不同的字符,世界各地的开发人员开始创建不同的字符集/编码,这些字符集/编码对 128 个 ASCII 字符进行编码,但也对额外的字符进行编码,以满足法语、希腊语或俄语等语言的需要。多年来,已经创建了许多遗留(并且仍然重要)文件,它们的字节表示由特定字符集/编码定义的字符。

国际标准化组织(ISO)和国际电工委员会(IEC)已经致力于在被称为 ISO/IEC 8859 的联合总括标准下标准化这些八位字符集/编码。结果是一系列名为 ISO/IEC 8859-1、ISO/IEC 8859-2 等的子标准。例如,ISO/IEC 8859-1(也称为 Latin-1)定义了一个字符集/编码,它由 ASCII 和覆盖大多数西欧国家的字符组成。此外,ISO/IEC 8859-2(也称为 Latin-2)定义了一个涵盖中欧和东欧国家的类似字符集/编码。

尽管 ISO/IEC 尽了最大努力,过多的字符集/编码仍然是不够的。例如,大多数字符集/编码只允许您创建英语和一种其他语言(或少量其他语言)组合的文档。例如,您不能使用 ISO/IEC 字符集/编码来创建使用英语、法语、土耳其语、俄语和希腊语字符组合的文档。

这个问题和其他问题正在由一个国际组织努力解决,该组织已经创建并正在继续开发一种单一通用字符集 Unicode 。因为 Unicode 字符是 ISO/IEC 字符的两倍大,所以 Unicode 使用几种称为 Unicode 转换格式(UTF) 的可变长度编码方案之一来编码 Unicode 字符以提高效率。例如,UTF-8 将 Unicode 字符集中的每个字符编码为一到四个字节(并向后兼容 ASCII)。

术语字符集字符编码经常互换使用。在 ISO/IEC 字符集的上下文中,它们的意思是相同的,其中码位是编码。但是,这些术语在 Unicode 的上下文中是不同的,Unicode 是字符集,UTF-8 是 Unicode 字符的几种可能的字符编码之一。

作者和读者类别概述

java.io包提供了几个作者和读者类,它们是抽象的WriterReader类的后代。图 8-7 揭示了作家阶层的层次结构。

images

图 8-7。FilterOutputStream不同,FilterWriter是抽象的。

图 8-8 揭示了阅读器类的层次结构。

images

图 8-8FilterInputStream不同,FilterReader是抽象的。

尽管 writer 和 reader 类的层次结构与它们的输出流和输入流相似,但还是有区别。例如,FilterWriterFilterReader是抽象的,而它们的FilterOutputStreamFilterInputStream等价物不是抽象的。另外,BufferedWriterBufferedReader不延伸FilterWriterFilterReader,而BufferedOutputStreamBufferedInputStream延伸FilterOutputStreamFilterInputStream

输出流和输入流类是在 JDK 1.0 中引入的。在它们发布后,设计问题出现了。比如FilterOutputStreamFilterInputStream本来应该是抽象的。但是,进行这些更改已经太晚了,因为这些类已经被使用了;进行这些更改会导致代码崩溃。JDK 1.1 的作者和读者类的设计者花时间来纠正这些错误。

images 关于BufferedWriterBufferedReader直接子类化WriterReader而不是FilterWriterFilterReader,相信这个变化和性能有关。对BufferedOutputStreamwrite()方法和BufferedInputStreamread()方法的调用导致对FilterOutputStreamwrite()方法和FilterInputStreamread()方法的调用。因为一个文件 I/O 活动(比如将一个文件复制到另一个文件)可能涉及许多write() / read()方法调用,所以您希望获得尽可能好的性能。通过不子类化FileWriterFileReader , BufferedWriterBufferedReader获得更好的性能。

为了简洁起见,在这一章中我只关注WriterReaderOutputStreamWriterOutputStreamReaderFileWriterFileReader类。

作家和读者

Java 提供了用于执行字符 I/O 的WriterReader类。Writer是所有 writer 子类的超类。下面列出了WriterOutputStream之间的区别:

  • Writer声明了几个append()方法,用于将字符追加到这个编写器。这些方法的存在是因为Writer实现了java.lang.Appendable接口,该接口与Formatter类(参见附录 C)一起用于输出格式化字符串。
  • Writer声明了额外的write()方法,包括一个方便的void write(String str)方法,用于将String对象的字符写入该编写器。

Reader是所有 reader 子类的超类。下表列出了ReaderInputStream之间的区别:

  • Reader声明了read(char[])read(char[], int, int)方法,而不是read(byte[])read(byte[], int, int)方法。
  • Reader没有声明一个available()方法。
  • Reader声明一个boolean ready()方法,当保证下一个read()调用不会阻塞输入时,该方法返回 true。
  • Reader声明了一个从字符缓冲区读取字符的int read(CharBuffer target)方法。(我在附录 c 中讨论CharBuffer)

OutputStreamWriter 和 InputStreamReader

具体的OutputStreamWriter类(一个Writer子类)是传入的字符序列和传出的字节流之间的桥梁。根据默认或指定的字符编码,写入此编写器的字符被编码为字节。

images 注意默认字符编码可通过file.encoding系统属性访问。

OutputStreamWriter write()方法的每次调用都会导致编码器在给定的字符上被调用。结果字节在写入基础输出流之前在缓冲区中累积。传递给write()方法的字符没有被缓冲。

OutputStreamWriter声明了四个构造函数,包括:

  • OutputStreamWriter(OutputStream out)在传入的字符序列(通过其append()write()方法传递给OutputStreamWriter)和底层输出流out之间创建一座桥梁。默认的字符编码用于将字符编码成字节。
  • OutputStreamWriter(OutputStream out, String charsetName)在传入的字符序列(通过其append()write()方法传递给OutputStreamWriter)和底层输出流out之间创建一座桥梁。charsetName标识用于将字符编码成字节的字符编码。当不支持命名字符编码时,这个构造函数抛出java.io.UnsupportedEncodingException

images 注意 OutputStreamWriter依赖于抽象的java.nio.charset.Charsetjava.nio.charset.CharsetEncoder类来执行字符编码。(我在附录 c 中讨论了这些类。)

下面的示例使用第二个构造函数创建到基础文件输出流的桥,以便可以将波兰语文本写入 ISO/IEC 8859-2 编码的文件。

FileOutputStream fos = new FileOutputStream("polish.txt");
OutputStreamWriter osw = new OutputStreamWriter(fos, "8859_2");
char ch = '\u0323'; // Accented N.
osw.write(ch);

具体的InputStreamReader类(一个Reader子类)是传入的字节流和传出的字符序列之间的桥梁。从该读取器读取的字符根据默认或指定的字符编码从字节解码。

InputStreamReader read()方法的每次调用都可能导致从底层输入流中读取一个或多个字节。为了有效地将字节转换为字符,可以从基础流中提前读取比满足当前读取操作所需更多的字节。

InputStreamReader声明了四个构造函数,包括:

  • InputStreamReader(InputStream in)在底层输入流in和输出字符序列(通过read()方法从InputStreamReader返回)之间创建一个桥梁。默认的字符编码用于将字节解码成字符。
  • InputStreamReader(InputStream in, String charsetName)在底层输入流in和输出字符序列(通过read()方法从InputStreamReader返回)之间创建一座桥梁。charsetName标识用于将字节解码成字符的字符编码。当不支持命名字符编码时,这个构造函数抛出UnsupportedEncodingException

images InputStreamReader依赖于抽象的Charsetjava.nio.charset.CharsetDecoder类来执行字符解码。(我在附录 c 中讨论了CharsetDecoder)

下面的示例使用第二个构造函数创建到基础文件输入流的桥,以便可以从 ISO/IEC 8859-2 编码的文件中读取波兰语文本。

FileInputStream fis = new FileInputStream("polish.txt");
InputStreamReader isr = new InputStreamReader(fis, "8859_2");
char ch = isr.read(ch);

images 注意 OutputStreamWriterInputStreamReader声明一个String getEncoding()方法,该方法返回正在使用的字符编码的名称。当编码有历史名称时,返回该名称;否则,将返回编码的规范名称。

文件写入器和文件读取器

FileWriter是一个方便的类,用于向文件中写入字符。它子类化了OutputStreamWriter,它的构造函数调用了OutputStreamWriter(OutputStream)。此类的一个实例等效于下面的代码片段:

FileOutputStream fos = new FileOutputStream(pathname);
OutputStreamWriter osw;
osw = new OutputStreamWriter(fos, System.getProperty("file.encoding"));

在第三章的中,我展示了一个带有File类的日志库(清单 3-20 ),它没有包含文件写入代码。清单 8-19 通过提供一个修改过的File类来解决这种情况,该类使用FileWriter将消息记录到一个文件中。

清单 8-19。将消息记录到实际文件中

`package logging;

import java.io.FileWriter;
import java.io.IOException;

class File implements Logger {
   private final static String LINE_SEPARATOR =
      System.getProperty("line.separator");
   private String dstName;
   private FileWriter fw;
   File(String dstName)
   {
      this.dstName = dstName;
   }
   @Override
   public boolean connect()
   {
      if (dstName == null)
         return false;
      try
      {
         fw = new FileWriter(dstName);
      }
      catch (IOException ioe)
      {
         return false;
      }
      return true;
   }
   @Override
   public boolean disconnect()
   {
      if (fw == null)
         return false;
      try
      {
         fw.close();
      }
      catch (IOException ioe)
      {
         return false;
      }
      return true;
   }
   @Override
   public boolean log(String msg)
   {
      if (fw == null)
         return false;
      try
      {
         fw.write(msg+LINE_SEPARATOR);
      }
      catch (IOException ioe)
      {
         return false;
      }       return true;
   }
}`

清单 8-19 重构清单 3-20 ,通过对connect()disconnect()log()方法中的每一个进行修改来支持FileWriter:

  • connect()尝试实例化FileWriter,成功后其实例保存在fw中;否则,fw继续存储其默认的空引用。
  • disconnect()试图通过调用FileWriterclose()方法来关闭文件,但只有当fw不包含其默认的空引用时。
  • log()试图通过调用FileWritervoid write(String str)方法将其String参数写入文件,但仅当fw不包含其默认的空引用时。

connect()的 catch 子句指定了IOException而不是FileNotFoundException,因为FileWriter的构造函数在无法连接到已有的普通文件时会抛出IOExceptionFileOutputStream的构造器抛出FileNotFoundException

log()write(String)方法将line.separator值(为了方便起见,我将其赋给了一个常数)附加到输出的字符串中,而不是附加\n,这将违反可移植性。

是一个从文件中读取字符的便利类。它子类化了InputStreamReader,它的构造函数调用了InputStreamReader(InputStream)。此类的一个实例等效于下面的代码片段:

FileInputStream fis = new FileInputStream(pathname);
InputStreamReader isr;
isr = new InputStreamReader(fis, System.getProperty("file.encoding"));

通常需要在文本文件中搜索特定字符串的出现。虽然正则表达式对于这项任务来说是理想的,但是我还没有讨论它们——我在附录 c 的新 I/O 的上下文中讨论正则表达式。因此,清单 8-20 给出了正则表达式的更详细的替代方案。

清单 8-20。查找包含与搜索字符串匹配的内容的所有文件

`import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;

class FindAll
{
   public static void main(String[] args)
   {
      if (args.length != 2)
      {
         System.err.println("usage: java FindAll start search-string");
         return;
      }
      if (!findAll(new File(args[0]), args[1]))
         System.err.println("not a directory");    }
   static boolean findAll(File file, String srchText)
   {
      File[] files = file.listFiles();
      if (files == null)
         return false;
      for (int i = 0; i < files.length; i++)
         if (files[i].isDirectory())
            findAll(files[i], srchText);
         else
         if (find(files[i].getPath(), srchText))
            System.out.println(files[i].getPath());
      return true;
   }
   static boolean find(String filename, String srchText)
   {
      try (BufferedReader br = new BufferedReader(new FileReader(filename)))
      {
         int ch;
         outer_loop:
         do
         {
            if ((ch = br.read()) == -1)
               return false;
            if (ch == srchText.charAt(0))
            {
               for (int i = 1; i < srchText.length(); i++)
               {
                  if ((ch = br.read()) == -1)
                     return false;
                  if (ch != srchText.charAt(i))
                     continue outer_loop;
               }
               return true;
            }
         }
         while (true);
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: "+ioe.getMessage());
      }
      return false;
   }
}`

清单 8-20 的FindAll类声明了main()findAll()find()类方法。

main()验证命令行参数的数量,必须是两个。第一个参数标识了搜索在文件系统中的起始位置,用于构造一个File对象。第二个参数指定搜索文本。然后,main()File对象和搜索文本传递给findAll(),搜索包含该文本的所有文件。

递归findAll()方法首先调用传递给该方法的File对象上的listFiles(),以获取当前目录中所有文件的名称。如果listFiles()返回 null,这意味着File对象没有引用现有的目录,findAll()返回 false 并输出一个合适的错误消息。

对于返回列表中的每个名称,findAll()或者在名称代表一个目录时递归调用自身,或者调用find()方法在文件中搜索文本;当文件包含此文本时,将输出文件的路径名字符串。

find()方法首先通过FileReader类打开由第一个参数标识的文件,然后将FileReader实例传递给BufferedReader实例以提高文件读取性能。然后,它进入一个循环,继续从文件中读取字符,直到到达文件的末尾。

如果当前读取的字符与搜索文本中的第一个字符匹配,则进入一个内部循环,从文件中读取后续字符,并将它们与搜索文本中的后续字符进行比较。当所有字符都匹配时,find()返回 true。否则,标记的 continue 语句用于跳过内部循环的剩余迭代,并将执行转移到标记的外部循环。在读取了最后一个字符后,仍然没有匹配,find()返回 false。

现在你知道了FindAll是如何工作的,你可能会想尝试一下。以下示例向您展示了我如何在我的 XP 平台上使用该应用:

java FindAll \prj\dev OpenGL

该示例在我的默认驱动器(C:)上的\prj\dev目录中搜索包含单词OpenGL(区分大小写)的所有文件,并生成以下输出:

\prj\dev\bj7\ch13\978-1-4302-3909-3_Friesen_13_Java7Android.doc
\prj\dev\bogl\article.html
\prj\dev\ew32pp\appa\CWinApp.html
\prj\dev\ws\articles\articles.html
\prj\dev\ws\tutorials\ct\air26gsp1\air26gsp1.html
\prj\dev\ws\tutorials\ct\jfx20bgsp1\jfx20bgsp1.html
\prj\dev\ws\tutorials\ct\jfx20bgsp2\jfx20bgsp2.html

如果我现在指定java FindAll \prj\dev opengl,我会观察到以下简短的输出:

\prj\dev\bogl\article.html

呈现了一个标准的基于 I/O 的用户界面,当您只想从命令行运行这个应用时,它是合适的。因为你可能更喜欢 GUI,清单 8-21 展示了这个应用的一个基于 Swing 的版本。

清单 8-21。重构FindAll以支持 GUI

`import java.awt.EventQueue;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
import java.io.IOException;

import javax.swing.BoxLayout;
import javax.swing.JButton; import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;

class FindAll
{
   final static String LINE_SEPARATOR = System.getProperty("line.separator");
   static JTextArea txtSrchResults;
   static JFrame f;
   static volatile String result;
   static JPanel createGUI()
   {
      JPanel pnl = new JPanel();
      pnl.setLayout(new BoxLayout(pnl, BoxLayout.Y_AXIS));
      JPanel pnlTemp = new JPanel();
      JLabel lblStartDir = new JLabel("Start directory");
      pnlTemp.add(lblStartDir);
      final JTextField txtStartDir = new JTextField(30);
      pnlTemp.add(txtStartDir);
      pnl.add(pnlTemp);
      pnlTemp = new JPanel();
      JLabel lblSrchText = new JLabel("Search text");
      pnlTemp.add(lblSrchText);
      lblSrchText.setPreferredSize(lblStartDir.getPreferredSize());
      final JTextField txtSrchText = new JTextField(30);
      pnlTemp.add(txtSrchText);
      pnl.add(pnlTemp);
      pnlTemp = new JPanel();
      JButton btnSearch = new JButton("Search");
      pnlTemp.add(btnSearch);
      pnl.add(pnlTemp);
      pnlTemp = new JPanel();
      txtSrchResults = new JTextArea(20, 30);
      pnlTemp.add(new JScrollPane(txtSrchResults));
      pnl.add(pnlTemp);
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 final String startDir = txtStartDir.getText();
                 final String srchText = txtSrchText.getText();
                 txtSrchResults.setText("");
                 Runnable r;
                 r = new Runnable()
                     {
                        @Override public void run()
                        {
                           if (!findAll(new File(startDir), srchText))
                           {
                              Runnable r;
                              r = new Runnable()
                                  {
                                     @Override
                                     public void run()
                                     {
                                        String msg = "not a directory";
                                        JOptionPane.showMessageDialog(f, msg);
                                     }
                                  };
                              EventQueue.invokeLater(r);
                           }
                        }
                     };
                 new Thread(r).start();
              }
           };
      btnSearch.addActionListener(al);
      return pnl;
   }
   static boolean findAll(File file, String srchText)
   {
      File[] files = file.listFiles();
      if (files == null)
         return false;
      for (int i = 0; i < files.length; i++)
         if (files[i].isDirectory())
            findAll(files[i], srchText);
         else
         if (find(files[i].getPath(), srchText))
         {
            result = files[i].getPath();
            Runnable r = new Runnable()
                         {
                            @Override
                            public void run()
                            {
                               txtSrchResults.append(result+LINE_SEPARATOR);
                            }
                         };
            EventQueue.invokeLater(r);
         }
      return true;
   }
   static boolean find(String filename, String srchText)
   {
      try (BufferedReader br = new BufferedReader(new FileReader(filename)))
      {          int ch;
         outer_loop:
         do
         {
            if ((ch = br.read()) == -1)
               return false;
            if (ch == srchText.charAt(0))
            {
               for (int i = 1; i < srchText.length(); i++)
               {
                  if ((ch = br.read()) == -1)
                     return false;
                  if (ch != srchText.charAt(i))
                     continue outer_loop;
               }
               return true;
            }
         }
         while (true);
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: "+ioe.getMessage());
      }
      return false;
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         f = new JFrame("FindAll");
                         f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                         f.setContentPane(createGUI());
                         f.pack();
                         f.setResizable(false);
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

清单 8-21 的FindAll类声明了几个类字段以及createGUI()findAll()find()main()类方法。因为大部分内容之前已经讨论过了(在第七章和本章的前面),我将只关注几个项目。

FindAll是一个多线程应用。与执行main()的主线程一样,FindAll的 GUI 也运行在事件分派线程(EDT)上,并创建一个工作线程来执行 EDT 之外的findAll()方法,以保持 GUI 的响应性。

在某些时候,线程必须与共享变量进行通信,这就是缺乏同步问题可能出现的地方。我通过创建一个单独的volatile result字段并使用final局部变量来消除这些问题。

result字段是volatile,以便 EDT 和工作线程可以在多核或多处理器平台上看到resultString参考值,其中每个核/处理器都有该字段的本地缓存副本。如果result不是volatile,当findAll()找到一个匹配时,EDT 可能看不到对分配给result的新String对象的引用,并且可能会将先前找到的匹配的副本附加到文本区域。(这在单处理器/单核平台上不是问题。)

虽然这个基本原理也适用于startDirsrchText局部变量,但是它们被声明为final而不是volatile。它们需要被声明为final,这样就可以从实现搜索按钮的动作监听器中的java.lang.Runnable的匿名类中访问它们。

如果您还记得,第四章指出final字段可以在没有同步的情况下安全地访问。因此,volatile对于final字段不是必需的,并且您不能同时声明一个字段为volatilefinal。(可以安全地访问final字段,但不一定是final引用字段引用的对象。因为String对象是不可变的,所以如果我在startDirsrchTextresult上调用String方法就不会有问题。)

搜索按钮的动作监听器在 runnable 中声明了一个 runnable,代码看起来可能很复杂。以下步骤解释了该代码的工作原理:

  1. 当用户单击 search 按钮时,它的actionPerformed()方法在 EDT 上被调用。
  2. actionPerformed()访问起始目录和搜索文本文本字段,清除结果文本区域,以便新的搜索结果不会附加到先前的搜索结果中,创建可运行的,并在 EDT 上启动一个工作线程(执行该可运行的)。
  3. 此后不久,工作线程将通过调用它的run()方法开始执行 runnable。
  4. run()调用findAll()开始搜索。如果findAll()返回 false,则创建一个新的 runnable,通过一个基于javax.swing.JOptionPane的对话框输出错误消息。工作线程执行java.awt.EventQueueinvokeLater()方法来确保对话框显示在 EDT 上。

images 注意附录 C 介绍了javax.swing.SwingWorker类,它简化了工作线程和 EDT 之间的通信。

清单 8-21 揭示了以下代码:

pnl.setLayout(new BoxLayout(pnl, BoxLayout.Y_AXIS));

这段代码使用 Swing 的javax.swing.BoxLayout类在垂直列中布局容器的组件。与java.awt.GridLayout不同,BoxLayout不会给每个组件相同的大小。

因为可能会返回许多搜索结果,所以 textarea 需要是可滚动的。但是,这个组件在默认情况下是不可滚动的,所以必须将其添加到 scrollpane 中。这个任务是在javax.swing.JScrollPane类的帮助下完成的。

JScrollPane提供需要滚动的组件调用的构造函数;比如JScrollPane(Component view)。相反,AWT 的java.awt.ScrollPane类要求你将组件传递给它的add()方法。

图 8-9 显示了FindAll的基于 Swing 的 GUI。

images

图 8-9。搜索结果显示在一个可滚动的文本区域。

图 8-10 显示了FindAll的 GUI 及其“非目录”对话框。

images

图 8-10。当您将“起始目录”文本字段留空,或者在该文本字段中输入文件名或不存在的目录的路径时,会出现一个对话框。

演习

以下练习旨在测试您对File以及各种流和编写器/读取器 API 的理解:

  1. 创建一个名为Touch的应用,用于将文件或目录的时间戳设置为当前或指定的时间。这个应用有以下用法语法:java Touch [-d *timestamp*] *pathname*。如果不指定[-d *timestamp*]pathname的时间戳设置为当前时间;否则设置为指定的 timestamp 值,格式为yyyy-MM-DD**HH:MM:ss**z(2010-08-13 02:37:45 UTC2006-04-22 12:35:45 EST为例)。提示:java.util.Date类(我在附录 C 中正式介绍过)有一个getTime()方法,它的返回值可以传递给FilesetLastModified()方法。此外,你会发现Date date = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z").parse(args[1]);System.err.println("invalid option: " + args[0]);很有用。(维基百科的“touch (Unix)”词条[ [en.wikipedia.org/wiki/Touch_(Unix)](http://en.wikipedia.org/wiki/Touch_(Unix)) ]向你介绍了一个名为touch的标准 Unix 程序。除了改变文件的访问和修改时间戳,touch用于创建一个新的空文件。)
  2. 创建一个名为Split的应用,用于将一个大文件分割成多个较小的part*x*文件(其中 x 从 0 开始递增);例如part0part1part2等等)。每个part*x*文件(可能除了保存剩余字节的最后一个part*x*文件)将具有相同的大小。这个应用有以下用法语法:java Split *pathname*。此外,您的实现必须使用BufferedInputStreamBufferedOutputStreamFileFileInputStreamFileOutputStream类。(我发现Split有助于在多张 CD/DVD 上存储无法放入一张 CD/DVD 的大文件,也有助于通过电子邮件将大文件发送给朋友。为了在 Windows 平台上重组零件文件,我使用了copy命令及其/B二进制选项。重组零件文件时,按顺序重组:part0part1part9part10等。)
  3. 从标准输入中读取文本行通常很方便,而InputStreamReaderBufferedReader类使这项任务成为可能。创建一个名为CircleInfo的应用,在获得一个链接到标准输入的BufferedReader实例后,进入一个循环,提示用户输入半径,将输入的半径解析成一个double值,并输出一对消息,报告基于这个半径的圆的周长和面积。
  4. FindAll的问题在于,您可以在正在进行的搜索过程中开始新的搜索操作。此外,没有办法停止正在进行的搜索,除非开始一个新的搜索或关闭窗口。当搜索正在进行时,通过禁用其搜索按钮来修改FindAll。另外,添加一个最初被禁用的 Stop 按钮,它允许您停止现有的搜索(也可以重新启用搜索)。

总结

应用经常与文件系统交互,以向文件输出数据和/或从文件输入数据。Java 的标准类库通过其经典的FileRandomAccessFile、流和写/读 API 支持文件系统访问。

Java 通过其具体的File类提供对底层平台可用文件系统的访问。File实例包含文件和目录的抽象路径名,这些文件和目录可能存在于它们的文件系统中,也可能不存在。

可以打开文件进行随机访问,在随机访问中,可以混合进行写和读操作,直到文件关闭。Java 通过提供具体的RandomAccessFile类来支持这种随机访问。

Java 使用流来执行 I/O 操作。流是任意长度的有序字节序列。字节通过输出流从应用流向目的地,通过输入流从源流向应用。

java.io包提供了几个输出流和输入流类,它们是抽象的OutputStreamInputStream类的后代。子类的例子包括FileOutputStreamBufferedInputStream

Java 的流类适合于字节流序列,但不适合于字符流序列,因为字节和字符是两回事,而且字节流不了解字符集和编码。

如果您需要流式传输字符,您应该利用 Java 的 writer 和 reader 类,它们被设计为支持字符 I/O(它们使用char而不是byte)。此外,writer 和 reader 类考虑了字符编码。

java.io包提供了几个作者和读者类,它们是抽象的WriterReader类的后代。子类的例子包括OutputStreamWriterFileWriterInputStreamReaderFileReaderBufferedReader

和文件系统一样,应用经常必须与网络和数据库进行交互。第九章介绍了标准类库的面向网络和面向数据库的 API。

九、与网络和数据库交互

访问应用外部的数据有三个目标:文件系统、网络和数据库。第八章向您介绍了面向文件系统的数据访问,而本章向您介绍了通过网络和数据库的数据访问。

与网络互动

一个网络是相互连接的节点(计算机和外围设备【例如打印机】)的集合,这些节点可以在用户之间共享硬件和软件。一个内部网是一个组织内部的网络,一个互联网是一个将组织连接在一起的网络。互联网是网络的全球网络。

images 注意内部网和互联网通常使用传输控制协议(TCP)、用户数据报协议(UDP)和互联网协议(IP)在节点之间进行通信。TCP 是双向通信协议,UDP 是单向通信协议,IP 是 TCP 和 UDP 执行其通信任务的基本通信协议。TCP、UDP 和 IP 与其他协议结合成一个模型,称为 TCP/IP(见[en.wikipedia.org/wiki/TCP/IP_model](http://en.wikipedia.org/wiki/TCP/IP_model))。

java.net包提供了支持运行在相同或不同主机(基于计算机的 TCP/IP 节点)上的进程(执行应用)之间的 TCP/IP 通信的各种类。在向您介绍了这些类之后,本节将介绍身份验证和 cookie 管理。

通过套接字通信

一个套接字是两个进程之间通信链路的一个端点。端点由一个识别主机的 IP 地址和一个识别在该网络节点上运行的进程的端口号组成。

一个进程将消息(字节序列)写入套接字,套接字将该消息分解成一系列数据包(可寻址消息块,通常称为 IP 数据报),并将这些数据包转发给另一个进程的套接字,套接字将它们重新组合成原始消息供该进程使用。图 9-1 显示了这种情况。

images

图 9-1。两个进程使用套接字进行通信。

根据图 9-1 ,主机 A 上的进程 A 向 socket 发送消息。主机 A 的网络管理软件,通常被称为协议栈,将该消息分解成一系列数据包(每个数据包包括目的主机的 IP 地址和端口号),并通过主机 A 的网络接口卡(NIC)将这些数据包发送到目的主机,即图中的主机 B。主机 B 的协议栈通过网卡接收数据包,并将它们重组为原始消息,然后提供给进程 B。当进程 B 与进程 a 通信时,情况正好相反。

IP 地址和端口号

IP 地址是 32 位或 128 位无符号整数,唯一标识网络主机和其他节点。32 位 IP 地址通常以句点分隔的十进制表示法指定为四个 8 位整数组成部分,其中每个组成部分都是范围从 0 到 255 的十进制整数,并通过句点与下一个组成部分分隔开(例如,127.0.0.1)。相比之下,128 位 IP 地址通常被指定为冒号分隔的十六进制表示法中的八个 16 位整数部分,其中每个部分是范围从 0 到 FFFF 的十六进制整数,并且通过冒号与下一个部分分隔开(例如,1080:0:0:0:8:800:200C:417A)。32 位 IP 地址通常被称为互联网协议第 4 版(IPv4)地址(参见[en.wikipedia.org/wiki/IPv4](http://en.wikipedia.org/wiki/IPv4))。类似地,128 位 IP 地址通常被称为互联网协议第 6 版(IPv6)地址(参见[en.wikipedia.org/wiki/IPv6](http://en.wikipedia.org/wiki/IPv6))。

端口号是 16 位无符号整数,唯一标识进程,即消息的来源或接收者。小于 1024 的端口号保留给标准进程。例如,端口号 25 传统上标识发送电子邮件的简单邮件传输协议(SMTP)进程,尽管端口号 587 目前普遍使用(见[en.wikipedia.org/wiki/Smtp](http://en.wikipedia.org/wiki/Smtp))。

TCP 用于通过来回发送消息来创建两台主机之间的持续对话。在此对话发生之前,必须在这些主机之间建立连接。建立连接后,TCP 进入一种模式,发送一个信息包并等待信息包正确到达的应答(或者当由于网络问题应答没有到达时等待超时)。这种发送/回复循环保证了可靠的连接。

因为建立连接需要时间,而且因为需要接收应答确认(或超时)而发送数据包也需要时间,所以 TCP 相当慢。UDP 不需要连接和数据包确认,比 TCP 快得多。然而,UDP 不像 TCP 那样可靠(不能保证数据包会正确到达,甚至到达),因为没有确认。此外,UDP 仅限于单包单向会话。

java.net包提供了用于执行基于 TCP 的通信的SocketServerSocket类。它还提供了用于执行 UDP 通信的DatagramSocketDatagramPacketMulticastSocket类。MulticastSocketDatagramSocket的子类。

套接字地址

后缀为Socket的类的实例与由 IP 地址和端口号组成的套接字地址相关联。

Socket类依赖于java.net.InetAddress类来表示套接字地址的 IPv4 或 IPv6 地址部分。它单独代表端口号。(另一个Socket-后缀类也利用了InetAddress。)

images 注意 InetAddress依赖其java.net.Inet4Address子类来表示 IPv4 地址,依赖其java.net.Inet6Address子类来表示 IPv6 地址。

InetAddress声明了几个获取InetAddress实例的类方法。这些方法包括以下内容:

  • InetAddress[] getAllByName(String host)返回一个存储与host相关的 IP 地址的InetAddress数组。您可以向此参数传递域名(例如“tutortutor.ca”)或 IP 地址(例如“70.33.247.10”)参数。(查看维基百科的“域名”词条[ [en.wikipedia.org/wiki/Domain_name](http://en.wikipedia.org/wiki/Domain_name) ]了解域名。)传递null会产生一个InetAddress实例,该实例存储环回接口的 IP 地址(稍后定义)。当找不到指定的host的 IP 地址,或者为全局 IPv6 地址指定了作用域标识符时,该方法抛出java.net.UnknownHostException
  • InetAddress getByAddress(byte[] addr)为给定的原始 IP 地址返回一个InetAddress对象。传递给addr的参数按照网络字节顺序(最高有效字节优先),其中最高顺序字节在addr[0]。对于 IPv4 地址,addr数组的长度必须是 4 个字节,对于 IPv6 地址必须是 16 个字节。当数组的长度既不是 4 也不是 16 时,这个方法抛出UnknownHostException
  • InetAddress getByAddress(String host, byte[] addr)根据提供的主机名和 IP 地址返回一个InetAddress实例。当数组的长度既不是 4 也不是 16 时,这个方法抛出UnknownHostException
  • InetAddress getByName(String host)相当于指定getAllByName(host)[0]
  • InetAddress getLocalHost()返回本地主机(当前主机)的地址,该地址由主机名 local host 或 IP 地址(通常为 127.0.0.1 [IPv4]或::1[IPv6])表示。当 localhost 不能被解析成地址时,这个方法抛出UnknownHostException
  • InetAddress getLoopbackAddress()返回回送地址(一个特殊的 IP 地址,允许网络管理软件将传出消息视为传入消息)。返回的InetAddress实例表示 IPv4 环回地址 127.0.0.1 或 IPv6 环回地址:1。返回的 IPv4 环回地址只是许多 127 格式地址中的一个。..,其中是范围从 0 到 255 的通配符。

一旦有了一个InetAddress实例,就可以通过调用实例方法来询问它,例如byte[] getAddress()(返回这个InetAddress对象的原始 IP 地址(按照网络字节顺序)和boolean isLoopbackAddress()(确定这个InetAddress实例是否代表一个回送地址)。

Java 1.4 引入了抽象的java.net.SocketAddress类来表示“没有协议附件”的套接字地址也许这个类的创建者预见到 Java 最终会支持低级别的通信协议,而不是广泛流行的互联网协议。

SocketAddress由具体的java.net.InetSocketAddress类子类化,该类将套接字地址表示为 IP 地址和端口号。它还可以表示主机名和端口号,并将尝试解析主机名。

InetSocketAddress实例是通过调用InetSocketAddress(InetAddress addr, int port)等构造函数创建的。创建实例后,可以调用InetAddress getAddress()int getPort()等方法来返回套接字地址组件。

插座选项

除了共享套接字地址的概念,各种带后缀的类也共享套接字选项的概念。套接字选项是用于配置套接字行为的参数。下面的 C 语言常量通过各种方法来标识Socket后缀类支持的套接字选项:

  • TCP_NODELAY:禁用 Nagle 的算法
    ( [en.wikipedia.org/wiki/Nagle's_algorithm](http://en.wikipedia.org/wiki/Nagle's_algorithm))。该选项对Socket有效。
  • SO_LINGER:指定持续关闭超时。该选项对Socket有效。
  • SO_TIMEOUT:指定阻塞套接字操作的超时时间。(永远不要挡!)该选项对SocketServerSocketDatagramSocket有效。
  • SO_BINDADDR:获取套接字的本地地址绑定。该选项对SocketServerSocketDatagramSocket有效。
  • SO_REUSEADDR:启用套接字重用地址。该选项对SocketServerSocketDatagramSocket有效。
  • SO_BROADCAST:启用套接字发送广播消息。该选项对DatagramSocket有效。
  • SO_SNDBUF:设置或获取最大套接字发送缓冲区,以字节为单位。该选项对SocketServerSocketDatagramSocket有效。
  • SO_RCVBUF:设置或获取最大套接字接收缓冲区,以字节为单位。该选项对SocketServerSocketDatagramSocket有效。
  • SO_KEEPALIVE:打开 socket keepalive。该选项对Socket有效。
  • SO_OOBINLINE:启用 TCP 紧急数据的内嵌接收。该选项对Socket有效。
  • IP_MULTICAST_IF:指定组播数据包的输出接口(在多宿主【例如,多个 NIC】主机上)。该选项仅对MulticastSocket有效。
  • IP_MULTICAST_LOOP:启用或禁用组播数据报的本地回环。该选项仅对MulticastSocket有效。
  • IP_TOS:为 TCP 或 UDP 套接字设置 IP 报头中的服务类型或流量类别字段。该选项对SocketDatagramSocket有效。

带后缀的类提供了设置/获取这些选项的 setter 和 getter 方法。例如,Socket声明void setKeepAlive(boolean on)设置SO_KEEPALIVE选项,MulticastSocket声明void setLoopbackMode(boolean disable)设置IP_MULTICAST_LOOP选项。查看关于java.netSocket后缀类的 JDK 文档,了解这些和其他套接字选项方法,了解更多关于各种套接字选项的信息。

images 注意适用于DatagramSocket套接字选项也适用于它的MulticastSocket子类。

套接字和服务器套接字

SocketServerSocket类允许您在客户端进程(例如,在您的桌面上运行的应用)和服务器进程(例如,在您的互联网服务提供商的计算机上运行的提供对万维网的访问的应用)之间执行基于 TCP 的通信。因为Socketjava.io.InputStreamjava.io.OutputStream类相关联,所以基于Socket类的套接字通常被称为流套接字。

Socket用于在客户端创建一个套接字。它声明了几个构造函数,包括下面的一对:

  • Socket(InetAddress address, int port)创建一个流套接字,并通过指定的 IP address将其连接到指定的port号。当创建套接字时出现 I/O 错误时,该构造函数抛出java.io.IOException,当传递给port的参数超出端口值的有效范围(从 0 到 65535)时抛出java.lang.IllegalArgumentException,当addressnull时抛出java.lang.NullPointerException
  • Socket(String host, int port)创建一个流套接字,并将其连接到指定的host上的指定port号。当hostnull时,这个构造函数相当于调用Socket(InetAddress.getByName(null), port)。它抛出与前面的构造函数相同的IOExceptionIllegalArgumentException实例。然而,当无法确定主机的 IP 地址时,它并不抛出NullPointerException,而是抛出UnknownHostException

当通过这些构造函数创建一个Socket实例时,它在连接到远程主机套接字地址之前绑定到一个任意的本地主机套接字地址。绑定使客户机套接字地址对服务器套接字可用,以便服务器进程可以通过服务器套接字与客户机进程通信。

Socket提供额外的构造函数给你灵活性。例如,Socket()Socket(Proxy proxy)创建未绑定和未连接的套接字。在使用这些套接字之前,需要通过调用void bind(SocketAddress bindpoint)将它们绑定到本地套接字地址,然后通过调用Socketconnect()方法进行连接,比如void connect(SocketAddress endpoint)

images 注意代理是一台出于安全目的位于内部网和互联网之间的计算机。代理设置由java.net.Proxy类的实例表示,帮助套接字通过代理进行通信。

另一个构造函数是Socket(InetAddress address, int port, InetAddress localAddr, int localPort),它让你通过localAddrlocalPort指定你自己的本地主机套接字地址。这个构造函数自动绑定到本地套接字地址,然后尝试连接到远程address

在创建了一个Socket实例,并可能在该实例上调用bind()connect()之后,应用通常会调用SocketInputStream getInputStream()OutputStream getOutputStream()方法来获得从套接字读取字节的输入流和向套接字写入字节的输出流。此外,一旦不再需要执行输入或输出操作,应用通常会调用Socketvoid close()方法来关闭套接字。

以下示例演示了如何在本地主机上创建一个绑定到端口号 1500 的套接字,然后访问其输入和输出流—为简洁起见,将忽略异常:

Socket socket = new Socket("localhost", 1500);
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();

我创建了一个GetTime应用,通过创建一个套接字连接到美国国家标准协会&技术(NIST)时间服务器来检索和输出当前时间,从而演示了Socket类。清单 9-1 展示了这个应用的源代码。

清单 9-1。根据 NIST 实施的白天协议获取并输出当前时间

`import java.io.InputStream;
import java.io.IOException;

import java.net.Socket;
import java.net.UnknownHostException;

class GetTime
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {          System.err.println("usage  : java GetTime server");
         System.err.println("example: java GetTime time.nist.gov");
         return;
      }
      try (Socket socket = new Socket(args[0], 13))
      {
         InputStream is = socket.getInputStream();
         int ch;
         while ((ch = is.read()) != -1)
            System.out.print((char) ch);
      }
      catch (UnknownHostException uhe)
      {
         System.err.println("unknown host: "+uhe.getMessage());
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: "+ioe.getMessage());
      }
   }
}`

清单 9-1 描述了一个创建一个Socket实例的应用,该实例在端口 13 上连接到一个远程服务器,该端口是为互联网的日间协议保留的。根据该协议,客户端套接字在端口 13 上连接到服务器进程,并且实现 Daytime 的进程立即向客户端套接字返回包含当前日期和时间的 ASCII ( [en.wikipedia.org/wiki/Ascii](http://en.wikipedia.org/wiki/Ascii))字符串。

images 互联网工程任务组发布备忘录,描述适用于互联网和互联网连接系统工作的方法、行为、研究或创新。这些备忘录统称为意见征询(RFC) 文件。RFC 867 描述了日间互联网协议([tools.ietf.org/html/rfc867](http://tools.ietf.org/html/rfc867)),并没有强制要求 ASCII 字符串的特定语法(其实现者可以自由使用他们自己的语法)。

各种时间服务器实现 Daytime(例如运行在与互联网域名time.nist.gov相关联的计算机上的服务器)。认识到这一事实后,GetTime要求您将时间服务器域名指定为命令行参数。例如,当您指定java GetTime time.nist.gov时,您将收到类似如下所示的输出:

55811 11-09-07 22:03:15 50 0 0 816.1 UTC(NIST) *

此输出符合日间协议的以下 NIST 语法:

JJJJJ YR-MO-DA HH:MM:SS TT L H msADV UTC(NIST) OTM

这些字段具有以下含义:

  • JJJJJ指定儒略日。
  • YR-MO-DA以年/月/日格式指定日期。
  • HH:MM:SS以小时/分钟/秒格式指定时间。该时间以协调世界时(UTC)表示—参见[en.wikipedia.org/wiki/UTC](http://en.wikipedia.org/wiki/UTC)
  • TT表示定时服务器是标准时间(ST)还是夏令时(DST),其中 00 表示标准时间,50 表示 DST。
  • L表示如何处理月末的闰秒;它是 0(无闰秒)、+1(加一个闰秒)或-1(减一个闰秒)中的一个。
  • H表示时间服务器的健康状况。它是 0(健康)或正整数(不健康)之一。
  • msADV表示为补偿网络延迟时间提前的毫秒数。
  • UTC(NIST)标识msADV值的发起者。
  • OTM表示准时标记。

查看位于[www.nist.gov/pml/div688/grp40/its.cfm](http://www.nist.gov/pml/div688/grp40/its.cfm)的网页,了解关于这种语法的更多信息。

虽然您可以通过InputStreamOutputStream引用从套接字读取字节或向套接字写入字节,但是您通常会通过将这些引用包装在java.io.BufferedReaderjava.io.PrintWriter类的实例中,将它们用作更方便的字符 I/O 流的基础,如下所示:

InputStreamReader isr = new InputStreamReader(is);
BufferedReader br = new BufferedReader(isr);
PrintWriter pw = new PrintWriter(os);

第一行创建了一个读取器,它将一个传入的字节流连接到一个传出的字符流,后者是根据默认的字符编码从字节中解码出来的(参见第八章)。然后将返回的读取器传递给BufferedReader以提高性能并获得对BufferedReaderString readLine()方法的访问,该方法可以方便地让您读取由换行符('\n')、回车符('\r')或回车符后紧跟换行符中的任何一个结束的字符串。

第三行使用PrintWriter(OutputStream out)构造函数创建一个PrintWriter实例,用于将字符串写入输出流,并通过内部创建的设置为默认字符编码的输出流编写器实例将这些字符转换为字节流。

当您调用这个构造函数时,当您调用一个println()方法时,它不会自动将字节刷新到输出流。为了确保输出字节,您需要在println()之后调用flush()方法。但是,您可以通过使用PrintWriter(OutputStream out, boolean autoFlush)构造函数并将true传递给autoFlush来确保刷新发生。

ServerSocket用于创建服务器端的一个 TCP 连接。服务器套接字等待通过网络传入的请求。它根据请求执行一些操作,然后可能向请求者返回一个结果。

当服务器套接字处理请求时,可能会有其他请求到达。这些请求存储在队列中,供后续处理。

ServerSocket声明了四个构造函数:

  • ServerSocket()创建一个未绑定的服务器套接字。您可以通过调用ServerSocket的两个bind()方法之一将这个套接字绑定到一个特定的套接字地址(客户端套接字与之通信)。绑定使服务器套接字地址对客户端套接字可用,以便客户端进程可以通过客户端套接字与服务器进程通信。当试图打开套接字时发生 I/O 错误时,该构造函数抛出IOException
  • ServerSocket(int port)创建绑定到指定的port值和与主机网卡之一相关联的 IP 地址的服务器套接字。当您将0传递到port时,会选择一个任意端口号。可以通过调用int getLocalPort()来检索端口号。传入连接指示(来自客户端的连接请求)的最大队列长度设置为 50。如果连接指示在队列已满时到达,则连接被拒绝。当试图打开套接字时发生 I/O 错误时,该构造函数抛出IOException,当port的值超出指定的有效端口值范围(0 到 65535,包括 0 和 65535)时,该构造函数抛出IllegalArgumentException
  • ServerSocket(int port, int backlog)相当于前面的构造函数,但是它也允许您通过向backlog传递一个正整数来指定最大队列长度。
  • ServerSocket(int port, int backlog, InetAddress bindAddr)相当于前面的构造函数,但是它也允许您指定服务器套接字绑定到的不同 IP 地址。此构造函数对于具有多个 NIC 的计算机非常有用,并且您希望侦听特定 NIC 上的连接指示。

创建服务器套接字后,服务器应用进入一个循环,首先调用ServerSocketSocket accept()方法监听连接指示,并返回一个Socket实例,让它与相关的客户端套接字通信。然后,它与客户端套接字进行通信,以执行某种处理。当处理完成时,服务器套接字调用客户机套接字的close()方法来终止它与客户机的连接。

images 注意 ServerSocket声明了一个void close()方法,用于在终止服务器应用之前关闭服务器套接字。

以下示例演示了如何创建一个绑定到当前主机上的端口 1500 的服务器套接字,侦听传入的连接指示,返回它们的套接字,在这些套接字上执行工作,以及关闭套接字—为简洁起见,将忽略异常:

ServerSocket ss = new ServerSocket(1500);
while (true)
{
   Socket socket = ss.accept();
   // obtain socket input/output streams and communicate with socket  
   socket.close();
}

accept()方法调用一直阻塞,直到有连接指示可用,然后返回一个Socket对象,以便服务器应用可以与其关联的客户端通信。通信发生后,套接字被关闭。

此示例假设套接字通信发生在服务器应用的主线程上,当处理需要时间来执行时,这是一个问题,因为服务器对传入连接指示的响应时间减少了。为了加快响应时间,通常需要与工作线程上的套接字进行通信,如下例所示:

ServerSocket ss = new ServerSocket(1500);
while (true)
{
   final Socket s = ss.accept();
   new Thread(new Runnable()
              {
                 private volatile Socket socket = s;
                 @Override
                 public void run()
                 {  
                    // obtain socket input/output streams and communicate with socket
                    try { socket.close(); } catch (IOException ioe) {}
                 }
              }).start();
}

每次连接指示到达时,accept()返回一个Socket实例,然后创建一个java.lang.Thread对象,它的 runnable 访问那个套接字,以便与工作线程上的套接字通信。

因为套接字分配(socket = s)发生在服务器应用的主线程上,并且因为socket也在工作线程上被访问,所以socket必须被声明为volatile,以解决主线程和工作线程在不同的处理器或内核上运行并且拥有它们自己的socket引用变量的缓存副本的情况。

images 提示虽然这个例子使用了Thread类,但是你也可以使用一个执行器(参见第六章)来代替。

为了演示ServerSocketSocket,我创建了ChatServerChatClient应用,让多个用户进行通信。清单 9-2 展示了ChatServer的源代码。

清单 9-2。让多个用户通信

`import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintWriter;

import java.net.ServerSocket;
import java.net.Socket;

import java.util.ArrayList; import java.util.List;

class ChatServer
{
   private final static int PORT_NO = 8010;
   private ServerSocket listener;
   private List clients;
   ChatServer() throws IOException
   {
      listener = new ServerSocket(PORT_NO);
      clients = new ArrayList<>();
      System.out.println("listening on port "+PORT_NO);
   }
   void runServer()
   {
      try
      {
         while (true)
         {
            Socket socket = listener.accept();
            System.out.println("accepted connection");
            Connection con = new Connection(socket);
            synchronized(clients)
            {
               clients.add(con);
               con.start();
               if (clients.size() == 1)
                  con.send("welcome...you're the first user");
               else
                  con.send("welcome...you're the latest of "+clients.size()+
                           " users");
            }
         }
      }
      catch (IOException ioe)
      {
         System.err.println("I/O error: "+ioe.getMessage());
         return;
      }
   }
   private class Connection extends Thread
   {
      private volatile BufferedReader br;
      private volatile PrintWriter pw;
      private String clientName;
      Connection(Socket s) throws IOException
      {
         br = new BufferedReader(new InputStreamReader(s.getInputStream()));
         pw = new PrintWriter(s.getOutputStream(), true);
      }
      @Override
      public void run() {
         String line;
         try
         {
            clientName = br.readLine();
            sendClientsList();
            while ((line = br.readLine()) != null)
               broadcast(clientName+": "+line);
         }
         catch (IOException ioe)
         {
            System.err.println("I/O error: "+ioe.getMessage());
         }
         finally
         {
            System.out.println(clientName+": "+"finished");
            synchronized(clients)
            {
               clients.remove(this);
               broadcast("now "+clients.size()+" users");
               sendClientsList();
            }
         }
      }
      private void broadcast(String message)
      {
         System.out.println("broadcasting "+message);
         synchronized(clients)
         {
            for (Connection con: clients)
               con.send(message);
         }
      }
      private void send(String message)
      {
         pw.println(message);
      }
      private void sendClientsList()
      {
         StringBuilder sb = new StringBuilder();
         synchronized(clients)
         {
            for (Connection con: clients)
            {
               sb.append(con.clientName);
               sb.append(" ");
            }
            broadcast("!"+sb.toString());
         }
      }
   }
   public static void main(String[] args)    {
      try
      {
         System.out.println("ChatServer starting");
         new ChatServer().runServer();
      }
      catch (IOException ioe)
      {
         System.err.println("unable to create server socket");
      }
   }
}`

清单 9-2 的ChatServer类由私有常量/非常数字段、一个构造函数、一个void runServer()方法、一个私有的Connection嵌套类(该嵌套类子类化Thread)和一个main()方法组成,该方法通过方法调用链调用该构造函数,后跟runServer()(参见第二章)。

构造函数试图创建服务器套接字;当成功时,它创建一个数组列表,存储代表来自聊天客户端的传入连接指示的Connection对象。

runServer()方法进入一个无限循环,首先调用accept()来等待连接指示,并返回一个Socket实例来与相关的客户端通信。然后它创建一个链接到Socket实例的Connection对象,将Connection对象添加到clients数组,启动Connection线程,并向与Connection对象的套接字关联的客户端发送问候消息。

Connection线程的run()方法开始运行时,它首先通过readLine()方法调用获得客户端的名称(运行客户端应用的用户的名称)。然后它调用Connectionvoid sendClientsList()方法来通知所有客户端关于最新加入聊天的客户端。

sendClientsList()通过首先建立一个感叹号(!)-以空格分隔的客户端名称为前缀的字符串,然后调用Connectionvoid broadcast(String message)方法将该字符串广播给所有参与聊天的客户端。

反过来,broadcast()对存储在clients数组中的每个Connection对象调用Connectionvoid send(message)方法。

然后,Connection线程的run()方法进入一个循环,该循环使用readLine()从客户端读取每一行,然后将这一行以客户端名称作为前缀广播给所有客户端。

在某些时候,当用户选择退出聊天时,客户端的套接字将被关闭。这个动作导致readLine()返回 null,从而结束循环并执行 try 语句的 finally 子句。该子句从clients数组中删除客户端的Connection对象,并广播标识剩余客户端数量及其名称的消息。

虽然ChatServer在概念上很简单,但是它对volatile和线程同步的使用使它显得更加困难。

我在任何可以被多线程访问的地方声明一个变量volatile。这个想法是为了确保ChatServer能够在多核/多处理器机器上工作,这些机器包含变量的独立缓存副本。

我使用同步来确保客户端对聊天服务器的状态有一致的看法。例如,runServer()在一个同步块中执行clients.add(con);con.send("welcome...you're the latest of "+clients.size()+" users");,并且还在另一个同步同一clients对象的同步块中执行clients.remove(this);sendClientsList();,从而在添加客户端和向该客户端发送关于当前客户端数量的消息之间不能删除客户端,并且在删除客户端和通知所有剩余客户端当前客户端数量之间不能添加客户端。

编译这个源代码(javac ChatServer.java)并运行应用(java ChatServer)。它通过在其命令窗口中显示以下输出来做出响应:

ChatServer starting
listening on port 8010

清单 9-3 展示了ChatClient的源代码。

清单 9-3。访问聊天服务器

`import java.awt.BorderLayout;
import java.awt.EventQueue;
import java.awt.GridLayout;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.PrintWriter;

import java.net.Socket;

import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;

import javax.swing.border.Border;
import javax.swing.border.EtchedBorder;

class ChatClient
{
   final static String SERVER_ADDR = "localhost";
   final static int SERVER_PORT = 8010;
   static Socket socket;
   static volatile BufferedReader br;
   static PrintWriter pw;
   static JButton btnSend;
   static JPanel createGUI()
   {
      JPanel pnlLayout = new JPanel();
      pnlLayout.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
      pnlLayout.setLayout(new BorderLayout());
      JPanel pnlLeft = new JPanel();
      pnlLeft.setLayout(new BorderLayout());
      final JTextField txtUsername = new JTextField(30);
      pnlLeft.add(txtUsername, BorderLayout.NORTH);
      final JTextArea txtInput = new JTextArea(5, 30);
      txtInput.setEnabled(false);
      pnlLeft.add(new JScrollPane(txtInput), BorderLayout.CENTER); final JTextArea txtOutput = new JTextArea(10, 30);
      txtOutput.setFocusable(false);
      pnlLeft.add(new JScrollPane(txtOutput), BorderLayout.SOUTH);
      pnlLayout.add(pnlLeft, BorderLayout.WEST);
      JPanel pnlRight = new JPanel();
      pnlRight.setLayout(new BorderLayout());
      final JTextArea txtUsers = new JTextArea(10, 10);
      txtUsers.setFocusable(false);
      Border border = BorderFactory.createEtchedBorder(EtchedBorder.LOWERED);
      txtUsers.setBorder(border);
      pnlRight.add(txtUsers, BorderLayout.NORTH);
      JPanel pnlButtons = new JPanel();
      pnlButtons.setLayout(new GridLayout(3, 1));
      final JButton btnConnect = new JButton("Connect");
      ActionListener al;
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 txtUsername.setFocusable(false);
                 String username = txtUsername.getText().trim();
                 try
                 {
                    socket = new Socket(SERVER_ADDR, SERVER_PORT);
                    btnConnect.setEnabled(false);
                    InputStreamReader isr;
                    isr = new InputStreamReader(socket.getInputStream());
                    br = new BufferedReader(isr);
                    pw = new PrintWriter(socket.getOutputStream(), true);
                    txtOutput.append(br.readLine()+"\n");
                    pw.println((!username.equals(""))?username:"unknown");
                    txtInput.setEnabled(true);
                    btnSend.setEnabled(true);
                    new Thread(new Runnable()
                               {
                                  @Override
                                  public void run()
                                  {
                                     String line;
                                     try
                                     {
                                        while ((line = br.readLine()) != null)
                                        {
                                           if (line.charAt(0) != '!')
                                           {
                                              txtOutput.append(line+"\n");
                                              continue;
                                           }
                                           txtUsers.setText("");
                                           String[] users;
                                           users = line.substring(1) .split(" ");
                                           for (String user: users)
                                           {
                                              txtUsers.append(user);
                                              txtUsers.append("\n");
                                           }
                                        }
                                     }
                                     catch (IOException ioe)
                                     {
                                        txtOutput.append("lost the link");
                                        return;
                                     }
                                   }
                               }).start();
                 }
                 catch (Exception e)
                 {
                    txtOutput.append("unable to connect to server");
                 }
              }
           };
      btnConnect.addActionListener(al);
      pnlButtons.add(btnConnect);
      btnSend = new JButton("Send");
      btnSend.setEnabled(false);
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 pw.println(txtInput.getText());
                 txtInput.setText("");
              }
           };
      btnSend.addActionListener(al);
      pnlButtons.add(btnSend);
      JButton btnQuit = new JButton("Quit");
      al = new ActionListener()
           {
              @Override
              public void actionPerformed(ActionEvent ae)
              {
                 try
                 {
                    if (socket != null)
                       socket.close();
                 }
                 catch (IOException ioe)
                 {
                 }
                 System.exit(0);               }
           };
      btnQuit.addActionListener(al);
      pnlButtons.add(btnQuit);
      pnlRight.add(pnlButtons, BorderLayout.SOUTH);
      pnlLayout.add(pnlRight, BorderLayout.EAST);
      return pnlLayout;
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         JFrame f = new JFrame("ChatClient");
                         f.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
                         f.setContentPane(createGUI());
                         f.pack();
                         f.setResizable(false);
                         f.setVisible(true);
                      }
                   };
      EventQueue.invokeLater(r);
   }
}`

清单 9-3 的ChatClient类包含常量/非常量字段,一个用于创建该应用图形用户界面(GUI)的JPanel createGUI()类方法,以及一个用于创建 GUI 和运行该应用的main()方法。

GUI 创建代码呈现了几个我在第七章中没有讨论的项目(为了简洁):

  • java.awt.Component类声明了一个用于设置组件的focusable状态的void setFocusable(boolean focusable)方法。换句话说,它决定了用户是否可以跳转到或点击组件来给予该组件输入焦点(例如,让用户在文本字段中输入字符)。将false传递给这个方法可以防止组件接收输入焦点,为此我在各种 textfield/textarea 组件上这样做。虽然我可以调用setEnabled(false)来达到同样的效果,但是我没有这样做,因为一个禁用的 textfield/textarea 的文本看起来很模糊,很难阅读(至少在默认的 Metal 外观下)。相比之下,当组件不可聚焦时,文本会很清晰,易于阅读。
  • java.awt.BorderLayout类被广泛用于布局 GUI。它允许您在关联容器的北、南、东、西和中心区域安排最多五个组件。组件根据它们的首选大小和容器大小的约束进行布局。南北分量可以水平拉伸;东分量和西分量可以垂直拉伸;中心组件可以水平和垂直拉伸,以填充任何剩余的空间。当添加一个组件到一个由边框布局管理的容器中时,java.awt.Containervoid add(Component comp, Object constraints)方法被调用,其中一个BorderLayout的基于java.lang.String的约束常量(例如NORTH)作为第二个参数,告诉布局管理器在哪里放置组件。

连接到 Connect、Send 和 Quit 按钮的监听器向您展示了如何创建一个连接到聊天服务器的套接字,如何与聊天服务器通信,以及如何关闭套接字。

ChatServerChatClient通过相同的端口号(8010)进行通信。另外,ChatClient通过指定 localhost (127.0.0.1)假设ChatServer运行在同一台计算机上。如果ChatServer在不同的计算机上运行,你应该指定那台计算机的域名/IP 地址。

编译清单 9-3 ( javac ChatClient.java)。假设ChatServer正在运行,通过在两个不同的命令窗口中执行java ChatClient来启动一对ChatClient实例。

图 9-2 显示了用户杰克和吉尔通过他们的聊天客户端进行交流。

images

图 9-2。杰克正准备给吉尔发信息。

在顶部的文本框中输入一个名称,然后点击连接按钮连接到聊天服务器。如果未指定名称,聊天客户端将选择未知作为用户名,单击连接后您将无法更改用户名。用户名文本框右侧的文本区显示了参与聊天的所有用户。

继续在用户名文本字段下方的输入文本区域中输入文本,然后单击发送按钮将输入的文本发送给所有用户。该文本出现在输入文本区域下方的输出文本区域中。

最后,单击退出按钮终止聊天。

数据套接字和组播套接字

DatagramSocketMulticastSocket类允许您在一对主机(DatagramSocket)或多个主机(MulticastSocket)之间执行基于 UDP 的通信。无论使用哪一个类,您都可以通过数据报包传递单向消息,数据报包是与DatagramPacket类的实例相关联的字节数组。

images 注意虽然你可能认为SocketServerSocket就是你需要的全部,DatagramSocket(及其MulticastSocket子类)有它们的用途。例如,考虑一个场景,其中一组机器需要偶尔告诉服务器它们还活着。偶尔丢失消息或者消息没有按时到达都没有关系。另一个例子是周期性广播股票价格的低优先级股票报价机。当一个包裹没有到达时,很可能下一个包裹会到达,然后你会收到最新价格的通知。在实时应用中,及时的交付比可靠或有序的交付更重要。

DatagramPacket声明了几个构造函数,其中DatagramPacket(byte[] buf, int length)是最简单的。这个构造函数要求你将字节数组和整数参数传递给buflength,其中buf是存储要发送或接收的数据的数据缓冲区,length(必须小于等于buf.length)指定要发送或接收的字节数(从buf[0]开始)。

下面的示例演示了此构造函数:

byte[] buffer = new byte[100]; DatagramPacket dgp = new DatagramPacket(buffer, buffer.length);

images 注意额外的构造函数让你在buf中指定一个偏移量来标识第一个输出或输入字节的存储位置,和/或让你指定一个目的套接字地址。

DatagramSocket描述 UDP 通信链路的客户端或服务器端的套接字。虽然这个类声明了几个构造函数,但我发现在本章中使用客户端的DatagramSocket()构造函数和服务器端的DatagramSocket(int port)构造函数很方便。当构造函数无法创建数据报套接字或将数据报套接字绑定到本地端口时,它会抛出java.net.SocketException

应用实例化DatagramSocket后,调用void send(DatagramPacket dgp)void receive(DatagramPacket dgp)发送和接收数据报包。

清单 9-4 展示了服务器环境中的DatagramPacketDatagramSocket

清单 9-4。从客户端接收数据报数据包,并将它们回显给客户端

`import java.io.IOException;

import java.net.DatagramPacket;
import java.net.DatagramSocket;

class DGServer
{
   final static int PORT = 10000;
   public static void main(String[] args) throws IOException
   {
      System.out.println("server is starting");
      try (DatagramSocket dgs = new DatagramSocket(PORT))
      {
         System.out.println("send buffer size = "+dgs.getSendBufferSize());
         System.out.println("receive buffer size = "+
                            dgs.getReceiveBufferSize());
         byte[] data = new byte[100];
         DatagramPacket dgp = new DatagramPacket(data, data.length);
         while (true)
         {
            dgs.receive(dgp);
            System.out.println(new String(data));
            dgs.send(dgp);
         }
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}`

清单 9-4 的main()方法首先创建一个DatagramSocket对象,并将套接字绑定到本地主机上的端口 10000。然后它调用DatagramSocketint getSendBufferSize()int getReceiveBufferSize()方法来获取SO_SNDBUFSO_RCVBUF套接字选项的值,然后输出这些值。

images 注意套接字与底层平台发送和接收缓冲区相关联,它们的大小通过调用getSendBufferSize()getReceiverBufferSize()来访问。同样,它们的大小可以通过调用DatagramSocketvoid setReceiveBufferSize(int size)void setSendBufferSize(int size)的方法来设置。虽然您可以调整这些缓冲区的大小来提高性能,但是 UDP 有一个实际的限制。在 IPv4 下,可以发送或接收的 UDP 数据包的最大大小是 65,507 字节,这是从 65,535 减去 8 字节 UDP 头和 20 字节 IP 头值得出的。虽然您可以指定一个更大的发送/接收缓冲区值,但这样做是浪费,因为最大的数据包被限制为 65,507 字节。此外,尝试发送/接收大于 65,507 字节的数据包(不管缓冲区大小)会导致IOException

main() next 实例化DatagramPacket,准备从客户端接收数据报分组,然后将分组回送到客户端。它假设数据包的大小不超过 100 字节。

最后,main()进入一个无限循环,该循环接收一个包,输出包内容,并将包发送回客户端—客户端的寻址信息存储在DatagramPacket中。

编译清单 9-4 ( javac DGServer.java)并运行应用(java DGClient)。您应该观察到与此处所示相同或相似的输出:

Server is starting
Send buffer size = 8192
Receive buffer size = 8192

清单 9-5 演示了客户端上下文中的DatagramPacketDatagramSocket

清单 9-5。向服务器发送数据包并从服务器接收数据包

`import java.io.IOException;

import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;

class DGClient
{
   final static int PORT = 10000;
   final static String ADDR = "localhost";
   public static void main(String[] args)
   {
      System.out.println("client is starting");
      DatagramSocket s = null;
      try (DatagramSocket dgs = new DatagramSocket())
      {
         byte[] buffer;
         buffer = "send me a datagram".getBytes();
         InetAddress ia = InetAddress.getByName(ADDR);
         DatagramPacket dgp = new DatagramPacket(buffer, buffer.length, ia,
                                                 PORT);
         dgs.send(dgp);
         byte[] buffer2 = new byte[100];
         dgp = new DatagramPacket(buffer2, buffer.length, ia, PORT);
         dgs.receive(dgp);
         System.out.println(new String(dgp.getData()));       }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}`

清单 9-5 类似于清单 9-4 ,但是有一个很大的区别。我使用DatagramPacket(byte[] buf, int length, InetAddress address, int port)构造函数在数据报包中指定服务器的目的地,这个目的地恰好是本地主机上的端口 10000。send()方法调用将数据包路由到这个目的地。

编译清单 9-5 ( javac DGClient.java)并运行应用(java DGClient)。假设DGServer也在运行,您应该在DGClient的命令窗口中观察到以下输出(以及DGServer命令窗口中该输出的最后一行):

client is starting
Send me a datagram

描述基于 UDP 的多播会话的客户端或服务器端的套接字。两个常用的构造函数是MulticastSocket()(创建一个不绑定到端口的组播套接字)和MulticastSocket(int port)(创建一个绑定到指定port的组播套接字)。

什么是多播?

前面的例子已经演示了单播,它发生在服务器向单个客户端发送消息的时候。然而,也可以向多个客户端广播相同的消息(例如,向已经向在线程序注册以接收该通知的一组家长的所有成员发送“学校因恶劣天气关闭”通知);这个活动被称为多播

服务器通过向一个特殊的 IP 地址(称为多播组地址)和一个特定的端口(由端口号指定)发送一系列数据报数据包来进行多播。想要接收这些数据报数据包的客户端创建一个使用该端口号的多播套接字。他们通过指定特殊 IP 地址的加入组操作请求加入组。此时,客户端可以接收发送到该组的数据报数据包,甚至可以向其他组成员发送数据报数据包。在客户端已经读取了它想要读取的所有数据报分组之后,它通过应用指定特殊 IP 地址的离开组操作来将自己从组中移除。

IPv4 地址 224.0.0.1 到 239.255.255.255(含)保留用作多播组地址。

清单 9-6 展示了一个组播服务器。

清单 9-6。组播数据报数据包

`import java.io.IOException;

import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.MulticastSocket; class MCServer
{
   final static int PORT = 10000;
   public static void main(String[] args)
   {
      try (MulticastSocket mcs = new MulticastSocket())
      {
         InetAddress group = InetAddress.getByName("231.0.0.1");
         byte[] dummy = new byte[0];
         DatagramPacket dgp = new DatagramPacket(dummy, 0, group, PORT);
         int i = 0;
         while (true)
         {
            byte[] buffer = ("line "+i).getBytes();
            dgp.setData(buffer);
            dgp.setLength(buffer.length);
            mcs.send(dgp);
            i++;
         }
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}`

清单 9-6 的main()方法首先通过MulticastSocket()构造函数创建一个MulticastSocket实例。多播套接字不需要绑定到端口号,因为端口号是作为随后创建的DatagramPacket实例的一部分与多播组的 IP 地址(231.0.0.1)一起指定的。(dummy数组的存在是为了防止NullPointerException对象从DatagramPacket构造函数中抛出——这个数组不是用来存储要广播的数据的。)

此时,main()进入一个无限循环,首先从String实例创建一个字节数组,并使用平台的默认字符编码(参见第八章)将 Unicode 字符转换成字节。(尽管无关的StringBuilderString对象是通过表达式"line "+i在每次循环迭代中创建的,但我并不担心它们对这个简短的一次性应用中的垃圾收集的影响。)

这个数据缓冲区随后通过调用它的void setData(byte[] buf)方法被分配给DatagramPacket实例,然后数据报包被广播给与端口 10000 和组播 IP 地址 231.0.0.1 相关联的组的所有成员。

编译清单 9-6 ( javac MCServer.java)并运行这个应用(java MCServer)。你不应该观察任何输出。

清单 9-7 展示了一个多播客户端。

清单 9-7。接收组播数据报数据包

`import java.io.IOException;

import java.net.DatagramPacket;
import java.net.InetAddress; import java.net.MulticastSocket;

class MCClient
{
   final static int PORT = 10000;
   public static void main(String[] args)
   {
      try (MulticastSocket mcs = new MulticastSocket(PORT))
      {
         InetAddress group = InetAddress.getByName("231.0.0.1");
         mcs.joinGroup(group);
         for (int i = 0; i < 10; i++)
         {
            byte[] buffer = new byte[256];
            DatagramPacket dgp = new DatagramPacket(buffer, buffer.length);
            mcs.receive(dgp);
            byte[] buffer2 = new byte[dgp.getLength()];
            System.arraycopy(dgp.getData(), 0, buffer2, 0, dgp.getLength());
            System.out.println(new String(buffer2));
         }
         mcs.leaveGroup(group);
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}`

清单 9-7 的main()方法首先通过MulticastSocket(int port)构造函数创建一个绑定到端口 10000 的MulticastSocket实例。

然后,它获得一个包含多播组 IP 地址 231.0.0.1 的InetAddress对象,并通过调用MulticastSocketvoid joinGroup(InetAddress mcastaddr)方法使用该对象加入该地址的组。

main() next 接收十个数据报包,打印它们的内容,并通过调用MulticastSocketvoid leaveGroup(InetAddress mcastaddr)方法离开组,使用相同的组播 IP 地址作为它的参数。

images joinGroup()leaveGroup()当试图加入或离开组时发生 I/O 错误,或者 IP 地址不是组播 IP 地址时,抛出IOException

因为客户端不知道字节数组到底有多长,所以它假设 256 个字节来确保数据缓冲区能够容纳整个数组。如果它试图打印出返回的数组,在实际数据被打印出来后,您会看到许多空白空间。为了消除这个空间,它调用DatagramPacketint getLength()方法来获得数组的实际长度,用这个长度创建第二个字节数组(buffer2,并使用System.arraycopy()——在第四章中讨论过——将这么多字节复制到buffer2。在将这个字节数组转换成一个String对象后(通过String(byte[] bytes) 构造函数,它使用平台的默认字符集——参见第八章了解字符集),它将结果字符打印到标准输出设备。

编译清单 9-7 ( javac MCClient.java)并运行这个应用(java MCClient)。您应该观察到类似如下的输出:

line 521103
line 521104
line 521105
line 521106
line 521107
line 521108
line 521109
line 521110
line 521111
line 521112

通过 URL 交流

统一资源定位符(URL) 是指定资源(例如网页)在基于 TCP/IP 的网络(例如因特网)上的位置的字符串。此外,它还提供了检索该资源的方法。例如,[tutortutor.ca](http://tutortutor.ca)是一个定位我的网站主页的 URL。http://前缀指定必须使用超文本传输协议(HTTP) 来检索位于tutortutor.ca的网页,该协议是位于 TCP/IP 之上的用于定位 HTTP 资源(例如网页)的高级协议。

urn 和 uri

统一资源名(URN) 是一个字符串,它并不暗示资源的可用性。即使资源可用,URN 也不提供定位它的方法。例如,urn:isbn:9781430234135标识了一本名为 Android Recipes 的电子书,如此而已。

urn 和 URL 是统一资源标识符(URIs) 的例子,统一资源标识符是用于标识名称(urn)或资源(URL)的字符串。每个 URN 和 URL 也是一个 URI,我在后面的章节中通过指定 URI 而不是 URL 来利用这个事实。

java.net包提供了用于访问基于 URL 的资源的URLURLConnection类。它还提供了用于编码和解码 URL 的URLEncoderURLDecoder类,以及用于执行基于 URI 的操作(例如,相对化)并返回包含结果的URL实例的URI类。

URL 和 URLConnection

URL类表示 URL,并提供对它们所引用的资源的访问。每个URL实例明确地标识一个互联网资源。

URL声明了几个构造函数,其中URL(String s)是最简单的。这个构造函数从传递给sString参数创建一个URL实例,演示如下:

try {    URL url = new URL("http://tutortutor.ca"); } catch (MalformedURLException murle) { }

这个例子创建了一个使用 HTTP 在[tutortutor.ca](http://tutortutor.ca)访问网页的URL对象。如果我指定了一个非法的 URL(例如,foo),构造函数将抛出java.net.MalformedURLException(一个IOException子类)。

虽然您通常会指定http://作为协议前缀,但这不是您唯一的选择。例如,当资源位于本地主机上时,您也可以指定file:///。此外,当资源存储在 JAR 文件中时,您可以将jar:添加到http://file:///的前面,如下所示:

jar:file:///C:./rt.jar!/com/sun/beans/TypeResolver.class

前缀jar:表示您想要访问一个 JAR 文件资源(例如,一个存储的类文件)。前缀file:///标识了本地主机的资源位置,在本例中是 Windows C:硬盘上当前目录中的rt.jar (Java 7 的运行时 JAR 文件)。

JAR 文件的路径后面跟一个感叹号(!)将 JAR 文件路径与 JAR 资源路径分开,JAR 资源路径恰好是这个 JAR 文件中的/com/sun/beans/TypeResolver.class classfile 条目(需要前导的/字符)。

images Oracle 的 Java 参考实现中的URL类支持附加协议,包括ftpmailto

在创建了一个URL对象之后,您可以调用各种URL方法来访问 URL 的各个部分。例如,String getProtocol()返回 URL 的协议部分(例如,http)。您还可以通过调用InputStream openStream()方法来检索资源。

openStream()创建到资源的连接,并返回一个InputStream实例,用于从该连接读取资源数据,如下所示:

try (InputStream is = url.openStream())
{
   int ch;
   while ((ch = is.read()) != -1)
      System.out.print((char) ch);
}

images 注意对于 HTTP 连接,会创建一个内部套接字,连接到通过 URL 的域名/IP 地址识别的服务器上的 HTTP 端口 80,除非您在域名/IP 地址后附加一个不同的端口号(例如[tutortutor.ca:8080](http://tutortutor.ca:8080))。

我创建了一个应用,演示了如何定位和访问任意资源。清单 9-8 展示了它的源代码。

清单 9-8。输出通过 URL 命令行参数识别的资源内容

`import java.io.InputStream;
import java.io.IOException;

import java.net.MalformedURLException;
import java.net.URL;

class GetResource
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java getresource url");
         return;
      }
      try
      {
         URL url = new URL(args[0]);
         try (InputStream is = url.openStream())
         {
            int ch;
            while ((ch = is.read()) != -1)
               System.out.print((char) ch);
         }
      }
      catch (MalformedURLException murle)
      {
         System.err.println("invalid url");
      }
      catch (IOException ioe)
      {
         System.err.println("i/o error: "+ioe.getMessage());
      }
   }
}`

编译这个源代码(javac GetResource.java)并执行java GetResource [tutortutor.ca](http://tutortutor.ca)。以下输出显示了返回网页的短前缀:

<!DOCTYPE html PUBLIC "-//w3c//dtd html 4.01//en"![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
 "http://www.w3.org/tr/html4/strict.dtd">

<html>
  <head>
    <title>
      TutorTutor -- /main
    </title>

openStream()是一个调用openConnection().getInputStream()的方便方法。URLURLConnection openConnection()URLConnection openConnection(Proxy proxy)方法都返回一个java.net.URLConnection类的实例,代表应用和 URL 之间的通信链接。

URLConnection为您提供对客户端/服务器通信的额外控制。例如,您可以使用此类将内容输出到接受内容的各种资源。相比之下,URL只允许你通过openStream()输入内容。

URLConnection声明了各种方法,包括:

  • InputStream getInputStream()返回从这个打开的连接中读取的输入流。
  • OutputStream getOutputStream()返回写入这个打开的连接的输出流。
  • void setDoInput(boolean doinput)指定这个URLConnection对象支持(传递truedoinput)或者不支持(传递falsedoinput)输入。因为true是默认的,你只需要将true传递给这个方法来记录你执行输入的意图(正如我在第十一章中演示的)。
  • void setDoOutput(boolean dooutput)指定这个URLConnection对象支持(通过truedooutput)或者不支持(通过falsedooutput)输出。因为默认值为 false,所以在执行输出之前必须调用这个方法(如第十一章中的所示)。
  • void setRequestProperty(String key, String value)设置请求属性(例如,HTTP 的accept属性)。当一个键已经存在时,它的值将被指定的值覆盖。

以下示例向您展示了如何从预创建变量url引用的URL对象中获取URLConnection对象,设置其dooutput属性,并获取用于写入资源的输出流:

URLConnection urlc = url.openConnection();
urlc.setDoOutput(true);
OutputStream os = urlc.getOutputStream();

URLConnectionjava.net.HttpURLConnectionjava.net.JarURLConnection子类化。这些类声明特定于使用 HTTP 协议或与基于 JAR 的资源交互的常量和/或方法。

images 为了简洁,我让你参考关于URLConnectionHttpURLConnectionJarURLConnection的 JDK 文档;以及第十一章的HttpURLConnection示例了解更多信息。

URLEncoder 和 URLDecoder

超文本标记语言(HTML)允许您将表单引入网页,向页面访问者请求信息。填写完表单的字段后,访问者单击表单的提交按钮(通常有不同的标签),表单内容(字段名称和值)被发送到某个服务器程序。

在将表单内容发送到服务器程序之前,web 浏览器通过替换空格和其他 URL 非法字符对数据进行编码,并将内容的多用途 Internet 邮件扩展(MIME)类型设置为application/x-www-form-urlencoded

images 注意数据是为 HTTP POST 和 GET 操作编码的。与 POST 不同,GET 需要一个查询字符串 (a?-包含编码内容的前缀字符串)附加到服务器程序的 URL。

java.net包提供了URLEncoderURLDecoder类来帮助你完成编码和解码表单内容的任务。

URLEncoder应用以下编码规则:

  • 字母数字字符“A”到“Z”、“A”到“Z”以及“0”到“9”保持不变。
  • 特殊字符。“、“-”、“*”和“_”保持不变。
  • 空格字符“”在 Internet Explorer 上转换为加号“+”,在 Firefox 上转换为“%20”。
  • 所有其他字符都是不安全的,首先使用某种编码方案转换成一个或多个字节。然后,每个字节由三个字符的字符串% xy 表示,其中 xy 是该字节的两位十六进制表示。推荐使用的编码方案是 UTF-8。然而,出于兼容性原因,当没有指定编码时,使用平台的默认编码。

例如,使用 UTF-8 作为编码方案,字符串"the string ü@foo-bar"被转换为"the+string+%c3%bc%40foo-bar"。在 UTF-8 中,字符ü被编码为两个字节 C3(十六进制)和 BC(十六进制),字符@被编码为一个字节 40(十六进制)。

URLEncoder声明了下面的类方法,用于对字符串进行编码:

String encode(String s, String enc)

这个方法使用编码方案enc将传递给sString参数翻译成application/x-www-form-urlencoded格式。它使用提供的编码方案来获得不安全字符的字节,并在不支持enc的值时抛出java.io.UnsupportedEncodingException

URLDecoder应用以下解码规则:

  • 字母数字字符“A”到“Z”、“A”到“Z”以及“0”到“9”保持不变。
  • 特殊字符。“、“-”、“*”和“_”保持不变。
  • 加号“+”/“% 20”被转换为空格字符“”。
  • 形式为% xy 的序列将被视为表示一个字节,其中 xy 是 8 位的两位十六进制表示。然后,所有连续包含一个或多个这些字节序列的子字符串将被其编码将产生这些连续字节的字符所替换。可以指定用于解码这些字符的编码方案;未指定时,使用平台的默认编码。

URLDecoder声明了以下用于解码编码字符串的类方法:

String decode(String s, String enc)

这个方法使用指定的编码方案解码一个application/x-www-form-urlencoded字符串。提供的编码用于确定% xy 形式的任何连续序列代表什么字符。当不支持enc的值时,抛出UnsupportedEncodingException

解码器处理非法编码的字符串有两种可能的方式。它要么不处理非法字符,要么抛出IllegalArgumentException。解码器采用哪种方法由实现来决定。

images 类似于 RFC 的万维网联盟推荐标准([www.w3.org/TR/html40/appendix/notes.html#non-ascii-chars](http://www.w3.org/TR/html40/appendix/notes.html#non-ascii-chars))规定,UTF-8 应该被用作encode()decode()的编码方案。不这样做可能会引入不兼容性。

我已经创建了一个应用,在前面的"the string ü@foo-bar""the+string+%c3%bc%40foo-bar"示例的上下文中演示了URLEncoderURLDecoder。清单 9-9 展示了应用的源代码。

清单 9-9。对编码后的字符串进行编码和解码

`import java.io.UnsupportedEncodingException;

import java.net.URLDecoder;
import java.net.URLEncoder;

class EncDec
{
   public static void main(String[] args) throws UnsupportedEncodingException
   {
      String encodedData = URLEncoder.encode("the string ü@foo-bar", "utf-8");
      System.out.println(encodedData);
      System.out.println(URLDecoder.decode(encodedData, "utf-8"));
   }
}`

images 注意你可能想看看维基百科的“百分比编码”主题([en.wikipedia.org/wiki/Percent-encoding](http://en.wikipedia.org/wiki/Percent-encoding))来了解更多关于 URL 编码的知识(以及更准确的百分比编码术语)。

URI

URI类代表 URIs(例如,urn 和 URL)。当 URI 是 URL 时,它不提供对资源的访问。

一个URI实例在最高级别存储一个符合以下语法的字符串:

[*scheme*:]*scheme-specific-part*[#*fragment*]

这种语法揭示了每个 URI 可选地以一个 scheme 开头,后跟一个冒号字符,其中一个方案可以被认为是一个用于获取互联网资源的应用级协议。但是,这个定义太窄了,因为它暗示 URI 总是一个 URL。一个方案可以与资源位置无关。例如,urn 是用于识别 urn 的方案。

一个方案后跟一个 scheme-specific-part ,它提供了该方案的一个实例。例如,给定[tutortutor.ca](http://tutortutor.ca) URI,tutortutor.ca就是 http 方案的一个实例。特定于方案的部分符合其方案允许的语法和 URI 的整体语法结构(包括哪些字符可以按字面意思指定,哪些字符必须编码)。

方案以可选的#-前缀 fragment 结束,这是一个短字符串,表示从属于另一个主要资源的资源。主资源由 URI 标识;该片段指向从属资源。例如,[tutortutor.ca/document.txt#line=5,10](http://tutortutor.ca/document.txt#line=5,10)在我的网站上识别名为document.txt的文本文档的第5行到第10行。(这个例子只是说明性的;资源实际上并不存在。)

URIs 可以分为绝对的和相对的。一个绝对 URI 以一个方案开头,后面跟一个冒号字符。早期的 URI 是绝对 URI 的一个例子。其他例子包括mailto:jeff@tutortutor.canews:comp.lang.java.help。将绝对 URI 视为以独立于该标识符出现的上下文的方式引用资源。用文件系统来类比,绝对 URI 相当于从根目录开始的文件的路径名。

一个相对 URI 不是以一个 scheme(后跟一个冒号)开始的。比如tutorials/tutorials.html。将相对 URI 视为以依赖于标识符出现的上下文的方式引用资源。使用文件系统类比,相对 URI 就像从当前目录开始的文件的路径名。

URIs 也可以分为不透明和等级制。一个不透明的 URI 是一个绝对的 URI,其特定于方案的部分不以正斜杠(/)字符开头。例子有[tutortutor.ca](http://tutortutor.ca)mailto:jeff@tutortutor.ca。不透明的 URIs 不会被解析(除了识别它们的模式),因为特定于模式的部分不需要被验证。

一个层次 URI 要么是一个绝对 URI,其特定于方案的部分以正斜杠字符开始,要么是一个相对 URI。

与不透明 URI 不同,分层 URI 的方案特定部分必须被解析成由以下语法标识的各种组件:

[//*authority*] [*path*] [?*query*] [#*fragment*]

authority 标识了 URI 命名空间的命名权限。如果存在,该组件以一对正斜杠字符开始,基于服务器或基于注册表,并以下一个正斜杠字符、问号字符或不再有字符(URI 的结尾)结束。基于注册中心的授权组件具有特定于方案的语法(因为不常用,所以不进行讨论),而基于服务器的授权组件通常采用以下语法:

[*userinfo*@] *host* [:*port*]

该语法指定基于服务器的授权组件可选地以用户信息(例如,用户名)和“at”(@)字符开始,然后以主机名继续,并且可选地以冒号字符和端口结束。例如,jeff@tutortutor.ca是一个基于服务器的权限组件,其中jeff表示用户信息,tutortutor.ca表示主机——没有端口。

path 根据权限组件(当存在时)或方案(当权限组件不存在时)标识资源的位置。一个路径分成一系列的路径段(路径的一部分),其中正斜杠字符用于分隔这些段。当第一个路径段以正斜杠字符开始时,路径是绝对的;否则,路径是相对的。例如,/a/b/c构成了一条包含三个路径段的路径— abc。此外,路径是绝对的,因为第一个路径段前面有一个正斜杠字符(a)。

query 标识要传递给资源的数据。资源使用该数据来获取或生成其他数据,并将其传递回调用方。例如,在[tutortutor.ca/cgi-bin/makepage.cgi?/software/Aquarium](http://tutortutor.ca/cgi-bin/makepage.cgi?/software/Aquarium)中,/software/Aquarium代表一个查询。根据这个查询,/software/Aquarium是要传递给资源(makepage.cgi)的数据,这个数据恰好是一个目录的绝对路径,这个目录的同名文件通过 Perl 脚本与样板 HTML 合并,生成一个结果 web 页面。

最后一个组件是 fragment 。虽然它看起来是 URI 的一部分,但它不是。当在检索操作中使用 URI 时,执行该操作的主资源使用该片段来检索从属资源。例如,makepage.cgi是主要资源,/software/Aquarium是从属资源。

前面的讨论揭示了一个完整的 URI 由方案、权限、路径、查询和片段组成;或者它由方案、用户信息、主机、端口、路径、查询和片段组件组成。要在前一种情况下构造一个URI实例,调用URI(String scheme, String authority, String path, String query, String fragment)构造函数。在后一种情况下,叫URI(String scheme, String userInfo, String host, int port, String path, String query, String fragment)

额外的构造函数可用于创建URI实例。例如,URI(String uri)通过解析uri创建一个URI。无论调用哪个构造函数,当产生的 URI 字符串有无效语法时,它都会抛出java.net.URISyntaxException

images 提示java.io.File类声明了一个URI toURI()方法,您可以调用该方法将File对象的抽象路径名转换为URI对象。内部 URI 的方案设定为file

URI声明各种 getter 方法,让您检索URI组件。例如,String getScheme()让您检索方案,String getFragment()返回一个 URL 解码的片段。该类还声明了boolean isAbsolute()boolean isOpaque()方法,当 URI 是绝对的和不透明的时,这些方法返回 true。

清单 9-10 展示了一个应用,让你了解 URI 组件以及绝对和不透明 URIs。

清单 9-10。了解 URI

`import java.net.URI;
import java.net.URISyntaxException;

class URIComponents
{
   public static void main(String[] args) throws URISyntaxException
   {
      if (args.length != 1)
      {
         System.err.println("usage: java uricomponents uri");
         return;
      }
      URI uri = new URI(args[0]);
      System.out.println("authority = "+uri.getAuthority());
      System.out.println("fragment = "+uri.getFragment());
      System.out.println("host = "+uri.getHost());
      System.out.println("path = "+uri.getPath());
      System.out.println("port = "+uri.getPort());
      System.out.println("query = "+uri.getQuery());
      System.out.println("scheme = "+uri.getScheme());
      System.out.println("scheme-specific part = "+uri.getSchemeSpecificPart());
      System.out.println("user info = "+uri.getUserInfo());
      System.out.println("uri is absolute: "+uri.isAbsolute());
      System.out.println("uri is opaque: "+uri.isOpaque());
   }
}`

编译清单 9-10 ( javac URIComponents.java)并如下运行应用:java URIComponents [tutortutor.ca/cgi-bin/makepage.cgi?/software/Aquarium](http://tutortutor.ca/cgi-bin/makepage.cgi?/software/Aquarium)。您将观察到以下输出:

Authority = tutortutor.ca
Fragment = null
Host = tutortutor.ca
Path = /cgi-bin/makepage.cgi
Port = -1
Query = /software/Aquarium
Scheme = http
Scheme-specific part = //tutortutor.ca/cgi-bin/makepage.cgi?/software/Aquarium
User Info = null
URI is absolute: true
URI is opaque: false

在创建了一个URI实例之后,您可以对其包含的 URI 执行规范化、解析和相对化操作(稍后讨论)。虽然您不能通过这个实例进行通信,但是出于通信的目的,您可以通过调用它的URL toURL()方法,将它转换成一个URL实例(假设 URI 实际上是一个 URL,而不是一个 URN 或其他东西)。

当 URI 不代表绝对 URL 时,该方法抛出IllegalArgumentException,当找不到 URL 的协议处理程序时(即 URL 不是以支持的协议如httpfile开始),或者当构造URL实例时发生其他错误时,该方法抛出MalformedURLException

正常化

正常化就是去掉不必要的“.”的过程。还有“..”来自分层 URI 路径组件的路径段。每个“.”段被删除。一个“..”仅当段前面有非-" .. "时,才删除该段分段。标准化对不透明的 URIs 没有影响。

URI声明了一个用于规范化 URI 的URI normalize()方法。该方法返回一个新的URI对象,该对象包含其调用者的 URI 的规范化等价物。

清单 9-11 展示了一个让你用normalize()进行实验的应用。

清单 9-11。URIs 正常化

`import java.net.URI;
import java.net.URISyntaxException;

class Normalize
{
   public static void main(String[] args) throws URISyntaxException
   {
      if (args.length != 1)
      {
         System.err.println("usage: java normalize uri");
         return;
      }
      URI uri = new URI(args[0]);
      System.out.println("normalized uri = "+uri.normalize());
   }
}`

编译清单 9-11 ( javac Normalize.java)并如下运行应用:java Normalize a/b/../c/./d。您应该观察到以下输出,它表明b不是规范化 URI 的一部分:

Normalized URI = a/c/d
分辨率

解决就是解决一个 URI 对另一个 URI 的过程,也就是所谓的基地。最终的 URI 按照 RFC 2396(见[tools.ietf.org/html/rfc2396](http://tools.ietf.org/html/rfc2396))规定的方式由两个 URIs 的部件建造而成,并从基地 URI 获得那些在原 URI 中没有规定的部件。对于分层 URIs,原始路径根据基础路径进行解析,然后进行规范化。

例如,解决原 URI docs/guide/collections/designfaq.html#28对基地 URI [java.sun.com/j2se/1.3/](http://java.sun.com/j2se/1.3/)的结果是结果 URI [java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28](http://java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28)。作为第二个例子,根据这个结果求解相对 URI ../../../demo/jfc/SwingSet2/src/SwingSet2.java得到[java.sun.com/j2se/1.3/demo/jfc/SwingSet2/src/SwingSet2.java](http://java.sun.com/j2se/1.3/demo/jfc/SwingSet2/src/SwingSet2.java)

支持绝对和相对 URIs 的解析,以及分层 URIs 情况下的绝对和相对路径的解析。

URI声明了URI resolve(String str)URI resolve(URI uri)方法,用于针对当前URI对象中包含的基本 URI,解析原始 URI 参数(传递给struri)(在该对象上调用了该方法)。当原始 URI 已经是绝对的或不透明的时,这些方法返回包含原始 URI 的新的URI对象或URI参数。否则,它们返回一个新的包含已解析 URI 的URI对象。当strurinull时,抛出NullPointerException。当str违反 RFC 2396 语法时,抛出IllegalArgumentException

清单 9-12 展示了一个让你用resolve(String)进行实验的应用。

清单 9-12。解决 URIs

`import java.net.URI;
import java.net.URISyntaxException;

class Resolve
{
   public static void main(String[] args) throws URISyntaxException
   {
      if (args.length != 2)
      {
         System.err.println("usage: java resolve baseuri uri");
         return;
      }
      URI uri = new URI(args[0]);
      System.out.println("resolved uri = "+uri.resolve(args[1]));
   }
}`

编译清单 9-12 ( javac Resolve.java)并如下运行应用:java Resolve [java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28](http://java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28)。您应该观察到以下输出:

Resolved URI = http://java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28
关系化

关系化是解析的逆。对于任何两个归一化的 URIs,相对化撤消了归结所做的工作,而归结撤消了相对化所做的工作。

URI声明了一个URI relativize(URI uri)方法,用于将它的uri参数与当前URI对象中的 URI 相对化(在该对象上调用了该方法)——urinullrelativize()抛出NullPointerException

images 注意对于任意两个规范化的URI实例uvu.relativize(u.resolve(v)).equals(v)u.resolve(u.relativize(v)).equals(v)评估为真。

relativize()对调用此方法的URI对象中的 URI 执行其URI参数的 URI 的相对化,如下所示:

  • 如果这个 URI 或参数 URI 是不透明的,或者如果两个 URIs 的方案和授权组件不相同,或者如果这个 URI 的路径不是参数 URI 的路径的前缀,则返回参数 URI。
  • 否则,用取自自变量 URI 的查询和片段组件,以及通过从自变量 URI 的路径的开头移除该 URI 的路径而计算的路径组件,来构造新的相对分层 URI。

清单 9-13 展示了一个让你用relativize()进行实验的应用。

清单 9-13。将 URIs 相对化

`import java.net.URI;
import java.net.URISyntaxException;

class Relativize
{
   public static void main(String[] args) throws URISyntaxException
   {
      if (args.length != 2)
      {
         System.err.println("usage: java relativize uri1 uri2");
         return;
      }
      URI uri1 = new URI(args[0]);
      URI uri2 = new URI(args[1]);
      System.out.println("relativized uri = "+uri1.relativize(uri2));
   }
}`

编译清单 9-13 ( javac Relativize.java)并如下运行应用:java Relativize [java.sun.com/j2se/1.3/](http://java.sun.com/j2se/1.3/) [java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28](http://java.sun.com/j2se/1.3/docs/guide/collections/designfaq.html#28)。您应该观察到以下输出:

Relativized URI = docs/guide/collections/designfaq.html#28

认证

RFC 1945:超文本传输协议—HTTP/1.0 ( [www.ietf.org/rfc/rfc1945.txt](http://www.ietf.org/rfc/rfc1945.txt))告诉您 HTTP 1.0 提供了一种简单的质询-响应机制,服务器可以使用这种机制来质询客户端访问某些资源的请求。此外,客户端可以使用这个机制来提供凭证(通常是用户名和密码)来认证(证明)客户端的身份。当提供的凭证满足服务器时,用户被授权(允许)访问资源。

为了质询客户端,原始服务器发出“401 未授权”消息。该消息包含一个WWW-Authenticate HTTP 头,它通过一个不区分大小写的令牌来标识一个认证方案(实现认证的方法)。令牌后面是逗号分隔的属性/值对序列,用于提供执行身份验证所需的特定于方案的参数。客户端回复一个提供凭证的Authorization头。

images 注意 HTTP 1.1 使得用代理认证客户端成为可能。为了质询客户端,代理服务器发出“407 需要代理认证”消息,该消息包括一个Proxy-Authenticate报头。客户端通过Proxy-Authorization报头回复。

基本认证和认证者类

HTTP 1.0 引入了基本认证方案,客户端通过用户名和密码来识别自己。基本身份验证方案的工作方式如下:

  • WWW-Authenticate头指定Basic作为令牌和一个单独的realm="*quoted string*"对,该对标识由浏览器地址引用的领域(一个资源所属的受保护空间,例如一组特定的网页)。
  • 作为对该标题的响应,浏览器显示一个对话框,在其中输入用户名和密码。
  • 输入之后,用户名和密码被连接成一个字符串(在用户名和密码之间插入一个冒号),该字符串是 base64 编码的,结果被放在一个发送回服务器的Authorization头中。(要了解更多关于 base64 编码的信息,请访问[en.wikipedia.org/wiki/Base64](http://en.wikipedia.org/wiki/Base64)查看维基百科的 Base64 条目。)
  • 服务器对这些凭证进行 base64 解码,并将其与存储在其用户名/密码数据库中的值进行比较。当存在匹配时,应用被授权访问该资源(以及属于该领域的任何其他资源)。

Greg Stein 在[test.webdav.org/](http://test.webdav.org/)维护着一个测试服务器,可以用来测试基本的身份验证等等。例如,当你在浏览器中指定[test.webdav.org/auth-basic/](http://test.webdav.org/auth-basic/)时,你会得到一个 401 响应,如清单 9-14 中的应用所示。

清单 9-14。通过输出服务器的各种 HTTP 头来展示对基本认证的需求

`import java.io.IOException;

import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLConnection;

import java.util.List;
import java.util.Map;

class BasicAuthNeeded
{
   public static void main(String[] args) throws IOException
   {
      String s = "http://test.webdav.org/auth-basic/";       URL url = new URL(s);
      URLConnection urlc = url.openConnection();
      Map<String,List> hf = urlc.getHeaderFields();
      for (String key: hf.keySet())
         System.out.println(key+": "+urlc.getHeaderField(key));
      System.out.println(((HttpURLConnection) urlc).getResponseCode());
   }
}`

这个应用连接到测试地址,并输出所有服务器发送的头及其响应代码。编译源代码(javac BasicAuthNeeded.java)后,运行应用(java BasicAuthNeeded)。您应该会看到类似于以下内容的输出:

null: HTTP/1.1 401 Authorization Required
WWW-Authenticate: Basic realm="basic auth area"
Date: Mon, 19 Sep 2011 03:06:06 GMT
Content-Length: 401
Keep-Alive: timeout=15, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=iso-8859-1
Server: Apache/2.0.54 (Debian GNU/Linux) DAV/2 SVN/1.3.2
401

WWW-Authenticate标题的realm属性显示basic auth area为领域。虽然没有显示,但是从user1user9的任何用户名和与用户名相同的密码都可以被指定进行认证。

为了将用户名和密码传回 HTTP 服务器,应用必须使用java.net.Authenticator类,如清单 9-15 所示。

清单 9-15。执行基本认证

`import java.io.IOException;

import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.PasswordAuthentication;
import java.net.URL;
import java.net.URLConnection;

import java.util.List;
import java.util.Map;

class BasicAuthGiven
{
   final static String USERNAME = "user1";
   final static String PASSWORD = "user1";
   static class BasicAuthenticator extends Authenticator
   {
      @Override
      public PasswordAuthentication getPasswordAuthentication()
      {
         System.out.println("Password requested from "+
                            getRequestingHost()+" for authentication "+                             "scheme "+getRequestingScheme());
         return new PasswordAuthentication(USERNAME, PASSWORD.toCharArray());
      }     
   }
   public static void main(String[] args) throws IOException
   {
      Authenticator.setDefault(new BasicAuthenticator());
      String s = "http://test.webdav.org/auth-basic/";
      URL url = new URL(s);
      URLConnection urlc = url.openConnection();
      Map<String,List> hf = urlc.getHeaderFields();
      for (String key: hf.keySet())
         System.out.println(key+": "+urlc.getHeaderField(key));
      System.out.println(((HttpURLConnection) urlc).getResponseCode());
   }
}`

因为Authenticator是抽象的,所以必须子类化。它的protected PasswordAuthentication getPasswordAuthentication()方法必须被覆盖以返回一个java.net.PasswordAuthentication对象中的用户名和密码。最后,必须调用void setDefault(Authenticator a)类方法来为整个 Java 虚拟机(JVM)安装Authenticator子类的一个实例。

安装了认证器后,当 HTTP 服务器需要基本认证时,JVM 将调用AuthenticatorrequestPasswordAuthentication()方法之一,这又调用覆盖的getPasswordAuthentication()方法。这可以在下面的输出中看到,它证明服务器已经授予了对资源的访问权限(某种程度上):

Password requested from test.webdav.org for authentication scheme basic
null: HTTP/1.1 404 Not Found
Date: Mon, 19 Sep 2011 03:09:11 GMT
Content-Length: 209
Keep-Alive: timeout=15, max=100
Connection: Keep-Alive
Content-Type: text/html; charset=iso-8859-1
Server: Apache/2.0.54 (Debian GNU/Linux) DAV/2 SVN/1.3.2
404

该输出表明授权已经成功。但是,它也显示找不到该资源。(我猜一个人不可能拥有一切。)

摘要认证

因为基本认证方案假设客户端和服务器之间的连接是安全和可信的,所以它以明文形式传输凭证(没有加密【将信息转换为不可读的内容的过程,称为明文,通过称为密码的算法,),除非拥有特殊知识,通常称为密钥);base64 可以很容易地被解码),使得窃听者很容易访问这些信息。为此,在 RFC 2616:超文本传输协议—HTTP/1.1 ( [www.ietf.org/rfc/rfc2616.txt](http://www.ietf.org/rfc/rfc2616.txt))中描述的 HTTP 1.1 引入了摘要认证方案来处理基本认证方案缺乏安全性的问题。根据这个方案,WWW-Authenticate报头指定Digest作为令牌。它还指定了realm=" 引用字符串"属性对。

摘要认证方案使用 MD5 ,这是一种单向加密哈希算法,用于加密密码。它还使用服务器生成的一次性随机数(随时间变化的值,如时间戳和访问者计数器)来防止重放(也称为中间人)攻击。虽然密码是安全的,但其余的数据以明文形式传输,窃听者可以访问。此外,客户端没有办法确定它正在与适当的服务器通信(服务器没有办法验证自己)。

images 有关摘要认证的更多信息,请查看维基百科的“摘要访问认证”条目([en.wikipedia.org/wiki/Digest_access_authentication](http://en.wikipedia.org/wiki/Digest_access_authentication))。

NTLM 和 Kerberos 认证

微软开发了一个专有的 NTLM 认证方案,它基于其 Windows NT 局域网(LAN)管理器认证协议,让客户通过他们的 Windows 凭证访问互联网信息服务器(IIS)资源。这种身份验证方案通常用于需要单点登录到内部网站点的企业环境中。WWW-Authenticate头指定NTLM为令牌;没有realm=" 引用字符串“属性对。与前两种面向请求的方案不同,NTLM 是面向连接的。

在 20 世纪 80 年代,MIT 开发了 Kerberos 来验证大型分布式网络上的用户。该协议比 NTLM 更加灵活和高效。此外,Kerberos 也被认为更安全。与 NTLM 相比,Kerberos 的一些优点是对服务器的身份验证更高效、相互身份验证以及将凭证委托给远程机器。

GSS API、SPNEGO 和协商认证方案

已经开发了各种安全服务来保护网络应用。服务包括多个版本的 Kerberos、NTLM 和 SESAME(Kerberos 的扩展)。因为很难重新设计一个应用来消除它对一个安全服务的依赖,并将其依赖于另一个安全服务,所以通用安全服务应用编程接口(GSS API)被开发为简化对这些服务的访问的标准 API。安全服务供应商通常将 GSS API 的实现作为一组库来提供,这些库随供应商的安全软件一起安装。GSS-API 实现的基础是实际的 Kerberos、NTLM 或其他用于提供凭证的机制

images 注意微软提供了自己专有的 GSS API 变体,被称为安全服务提供商接口(SSPI),它高度特定于 Windows,并在一定程度上与 GSS API 互操作。

一对联网的对等体(可以是客户机或服务器的主机)可能有多个已安装的 GSS-API 实现可供选择。因此,这些对等体使用简单且受保护的 GSS-API 协商(SPNEGO)伪机制来识别共享的 GSS-API 机制,做出适当的选择,并基于该选择建立安全上下文。

微软的协商认证方案(在 Windows 2000 中引入)使用 SPNEGO 来选择用于 HTTP 认证的 GSS-API 机制。最初,这个方案只支持 Kerberos 和 NTLM。在集成 Windows 身份验证(以前称为 NTLM 身份验证,也称为 Windows NT 挑战/响应身份验证)下,当 Internet Explorer 试图从 IIS 访问受保护的资源时,IIS 会向该浏览器发送两个WWW-Authenticate标头。第一个报头有Negotiate作为令牌;第二个报头有NTLM作为令牌。因为Negotiate最先被列出,它最先被 ie 浏览器识别。识别后,浏览器会将 NTLM 和 Kerberos 信息返回给 IIS。当下列情况成立时,IIS 使用 Kerberos:

  • 客户端是 Internet Explorer 5.0 或更高版本。
  • 服务器是 IIS 5.0 或更高版本。
  • 操作系统是 Windows 2000 或更高版本。
  • 客户端和服务器都是同一个域或受信任域的成员。

否则,使用 NTLM。如果 Internet Explorer 没有识别出Negotiate,它会通过 NTLM 认证方案将 NTLM 信息返回给 IIS。

Java 客户端可以提供一个Authenticator子类,其getPasswordAuthentication()方法检查从protected final String getRequestingScheme()方法返回的方案名,以确定当前方案是否为"negotiate"。在这种情况下,该方法可以将用户名和密码传递给 HTTP SPNEGO 模块(假设需要它们—没有可用的凭据缓存),如以下代码片段所示:

class MyAuthenticator extends Authenticator
{
   @Override
   public PasswordAuthentication getPasswordAuthentication()
   {
      if (getRequestingScheme().equalsIgnoreCase("negotiate"))
      {
         String krb5user; // Assume Kerberos 5.
         char[] krb5pass;
         // get krb5user and krb5pass in your own way
         ...
         return (new PasswordAuthentication(krb5user, krb5pass));
      }
      else
      {
         ...
      }
   }
}

images 注意有关 Java 对 SPNEGO 和其他认证方案支持的更多信息,请查看位于[download.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html](http://download.oracle.com/javase/7/docs/technotes/guides/net/http-auth.html)的 JDK 7 文档的“Http 认证”页面。

服务器应用通常使用 HTTP cookies (状态对象)——cookies来保存客户端上的少量信息。例如,购物车中当前所选商品的标识符可以存储为 cookies。最好将 cookie 存储在客户端,而不是服务器上,因为可能会有数百万个 cookie(取决于网站的受欢迎程度)。在这种情况下,不仅服务器需要大量的存储空间来存储 cookie,而且搜索和维护 cookie 也非常耗时。

images 注意查看维基百科的“HTTP cookie”条目([en.wikipedia.org/wiki/HTTP_cookie](http://en.wikipedia.org/wiki/HTTP_cookie))快速复习一下 cookie。

服务器应用将 cookie 作为 HTTP 响应的一部分发送给客户端。客户端(例如,网络浏览器)将 cookie 作为 HTTP 请求的一部分发送给服务器。在 Java 5 之前,应用使用URLConnection类(及其HttpURLConnection子类)来获取 HTTP 响应的 cookie 并设置 HTTP 请求的 cookie。String getHeaderFieldKey(int n)String getHeaderField(int n)方法用于访问响应的Set-Cookie头,而void setRequestProperty(String key, String value)方法用于创建请求的Cookie头。

images RFC 2109: HTTP 状态管理机制([www.ietf.org/rfc/rfc2109.txt](http://www.ietf.org/rfc/rfc2109.txt))描述了Set-CookieCookie头。

Java 5 引入了抽象的java.net.CookieHandler类作为回调机制,将 HTTP 状态管理连接到 HTTP 协议处理程序(想想具体的HttpURLConnection子类)。一个应用通过CookieHandler类的void setDefault(CookieHandler cHandler)类方法安装一个具体的CookieHandler子类作为系统范围的 cookie 处理程序。一个伴随的CookieHandler getDefault()类方法返回这个 cookie 处理程序,当系统范围的 cookie 处理程序还没有安装时,这个处理程序为空。

HTTP 协议处理程序访问响应和请求头。该处理程序调用系统范围的 cookie 处理程序的void put(URI uri, Map<String, List<String>> responseHeaders)方法将响应 cookie 存储在 cookie 缓存中,并调用Map<String, List<String>> get(URI uri, Map<String, List<String>> requestHeaders)方法从该缓存中获取请求 cookie。与 Java 5 不同,Java 6 引入了CookieHandler的具体实现,以便 HTTP 协议处理程序和应用可以使用 cookies。

具体的java.net.CookieManager类扩展了CookieHandler来管理 cookies。一个CookieManager对象被初始化如下:

  • 用一个饼干店来储存饼干。cookie 存储基于java.net.CookieStore接口。
  • 使用 cookie 策略来确定接受哪些 cookie 进行存储。cookie 策略基于java.net.CookiePolicy接口。

通过调用CookieManager()构造函数或CookieManager(CookieStore store, CookiePolicy policy)构造函数创建一个 cookie 管理器。CookieManager()构造函数调用带有null参数的后一个构造函数,使用默认的内存 cookie 存储和默认的从仅原始服务器接受 cookie 策略。除非您计划创建自己的CookieStoreCookiePolicy实现,否则您很可能会使用默认的构造函数。以下示例创建并建立一个新的CookieManager对象作为系统范围的 cookie 处理程序:

CookieHandler.setDefault(new CookieManager());

除了前面提到的构造函数,CookieManager还声明了以下方法:

  • Map<String, List<String>> get(URI uri, Map<String, List<String>> requestHeaders)返回从路径与uri的路径匹配的 cookie 存储中获得的 cookie 的CookieCookie2请求头的不可变映射。虽然这个方法的默认实现不使用requestHeaders,但是子类可以使用它。发生 I/O 错误时抛出IOException
  • CookieStore getCookieStore()返回 cookie 管理器的 cookie 存储。
  • void put(URI uri, Map<String, List<String>> responseHeaders)存储所有适用的 cookie,这些 cookie 的Set-CookieSet-Cookie2响应头是从指定的uri值中检索的,并(与所有其他响应头一起)放在 cookie 存储中不可变的responseHeaders映射中。发生 I/O 错误时抛出IOException
  • void setCookiePolicy(CookiePolicy cookiePolicy)将 cookie 管理器的 cookie 策略设置为CookiePolicy.ACCEPT_ALL(接受所有 cookie)、CookiePolicy.ACCEPT_NONE(不接受 cookie)或CookiePolicy.ACCEPT_ORIGINAL_SERVER(仅接受来自原始服务器的 cookie—这是默认设置)之一。将null传给这个方法对当前的政策没有影响。

与由 HTTP 协议处理程序调用的get()put()方法相反,应用使用getCookieStore()setCookiePolicy()方法。考虑清单 9-16 中的。

清单 9-16。列出特定域的所有 cookies】

`import java.io.IOException;

import java.net.CookieHandler;
import java.net.CookieManager;
import java.net.CookiePolicy;
import java.net.HttpCookie;
import java.net.URL;

import java.util.List;

class ListAllCookies
{
   public static void main(String[] args) throws IOException
   {
      if (args.length != 1)       {
          System.err.println("usage: java ListAllCookies url");
          return;
      }

CookieManager cm = new CookieManager();
      cm.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
      CookieHandler.setDefault(cm);
      new URL(args[0]).openConnection().getContent();
      List cookies = cm.getCookieStore().getCookies();
      for (HttpCookie cookie: cookies)
      {
           System.out.println("Name = "+cookie.getName());
           System.out.println("Value = "+cookie.getValue());
           System.out.println("Lifetime (seconds) = "+cookie.getMaxAge());
           System.out.println("Path = "+cookie.getPath());
           System.out.println();
      }
   }
}`

清单 9-16 描述了一个命令行应用,它从它的单个域名参数中获取并列出所有的 cookies。

在创建 cookie 管理器并调用setCookiePolicy()来设置 cookie 管理器的策略以接受所有 cookie 之后,ListAllCookies安装 cookie 管理器作为系统范围的 cookie 处理程序。接下来,它连接到由命令行参数标识的域并读取内容(通过URLObject getContent()方法)。

cookie 存储通过getCookieStore()获得,并通过它的List<HttpCookie> getCookies()方法检索所有未过期的 cookie。对于这些java.net.HttpCookieString getName()String getValue()和其他HttpCookie方法的每一个,都被调用来返回特定于 cookie 的信息。

调用java ListAllCookies [apress.com](http://apress.com)产生了以下输出:

Name = frontend
Value = tk95grc7tko42ghghu3qcep5l6
Lifetime (seconds) = 29985
Path = /

images 注意关于 cookie 管理的更多信息,包括展示如何创建自己的CookiePolicyCookieStore实现的例子,请查看Java 教程的“使用 cookie”课程([java.sun.com/docs/books/tutorial/networking/cookies/index.html](http://java.sun.com/docs/books/tutorial/networking/cookies/index.html))。

与数据库互动

一个数据库 ( [en.wikipedia.org/wiki/Database](http://en.wikipedia.org/wiki/Database))是一个有组织的数据集合。尽管数据库有很多种(例如,层次型、面向对象型和关系型),但是将数据组织成相互关联的表格的关系型数据库是很常见的。(每个表行存储一个单项,比如一个员工,每列存储一个单项属性,比如一个员工的姓名。)

除了最普通的数据库(如第八章的平面文件数据库),数据库都是通过数据库管理系统 (DBMS)创建和管理的。关系数据库系统(RDBMSes)支持结构化查询语言 (SQL)来处理表格等等。

images 注意为了简洁起见,我假设您熟悉 SQL。如果没有,你可能想看看维基百科的“SQL”条目([en.wikipedia.org/wiki/SQL](http://en.wikipedia.org/wiki/SQL))的介绍。

Java 通过其面向关系数据库的 JDBC (Java 数据库连接)API 支持数据库创建、访问等,本节将向您介绍 JDBC。在此之前,它向您介绍了 Java DB,我将用它来演示各种 JDBC 特性。

Java DB

最初由 Sun Microsystems 作为 JDK 6 的一部分(不包括在 JRE 中)引入,以给开发人员一个 RDBMS 来测试他们的 JDBC 代码, Java DB 是 Apache 的开源 Derby 产品的发行版,它基于 IBM 的 Cloudscape RDBMS 代码库。这种纯 Java RDBMS 也与 JDK 7 捆绑在一起(不在 JRE 中)。它是安全的,支持 JDBC 和 SQL(包括事务、存储过程和并发),并且占用空间小——其核心引擎和 JDBC 驱动程序占用 2MB。

images 一个 JDBC 驱动是一个用于与数据库通信的 classfile 插件。当我在本章后面介绍 JDBC 的时候,我会对 JDBC 车手有更多的了解。

Java DB 能够在嵌入式环境或客户机/服务器环境中运行。在嵌入式环境中,应用通过 Java DB 的嵌入式驱动程序访问数据库引擎,数据库引擎与应用运行在同一个 JVM 中。图 9-3 展示了嵌入式环境架构,其中数据库引擎嵌入在应用中。

images

图 9-3。启动或关闭嵌入式数据库引擎不需要单独的进程。

在客户机/服务器环境中,客户机应用和数据库引擎在独立的 JVM 中运行。客户端应用通过 Java DB 的客户端驱动程序访问网络服务器。网络服务器与数据库引擎运行在同一个 JVM 中,它通过嵌入式驱动程序访问数据库引擎。图 9-4 展示了这种架构。

images

图 9-4。多个客户端通过网络服务器与同一个数据库引擎通信。

Java DB 将图 9-3 和图 9-4 中的数据库部分实现为与数据库同名的目录。在这个目录中,Java DB 创建一个log目录来存储事务日志,一个seg0目录来存储数据文件,一个service.properties文件来存储配置参数。

images 注意 Java DB 不提供删除(销毁)数据库的 SQL 命令。销毁数据库需要手动删除其目录结构。

Java DB 安装和配置

当使用默认设置安装 JDK 7 时,捆绑的 Java DB 会安装到 Windows 平台上的%JAVA_HOME%\db中,或者 Unix/Linux 平台上相同位置的db子目录中。(为了方便起见,我在表示环境变量路径时采用了 Windows 惯例。)

images 注意我在本章中关注 Java DB 10.8.1.2,因为它包含在 JDK 7 版本 1.7.0-b147 中,这是本书所基于的 Java 版本。

db目录包含五个文件和以下一对子目录:

  • bin目录包含用于设置嵌入式和客户机/服务器环境、运行命令行工具以及启动/停止网络服务器的脚本。您应该将这个目录添加到您的PATH环境变量中,以便您可以从文件系统中的任何地方方便地执行它的脚本。
  • lib目录包含各种 JAR 文件,这些文件包含引擎库(derby.jar)、命令行工具库(derbytools.jarderbyrun.jar)、网络服务器库(derbynet.jar)、网络客户端库(derbyclient.jar)和各种语言环境库。该目录还包含derby.war,用于在/derbynet相对路径注册网络服务器的 servlet——也可以通过 servlet 接口远程管理 Java DB 网络服务器(参见[db.apache.org/derby/docs/10.8/adminguide/cadminservlet98430.html](http://db.apache.org/derby/docs/10.8/adminguide/cadminservlet98430.html))。

此外,%JAVA_HOME%\demo\db目录包含各种 Java DB 演示。

在运行工具和演示以及启动/停止网络服务器之前,您必须设置DERBY_HOME环境变量。通过set DERBY_HOME=%JAVA_HOME%\db为 Windows 设置这个变量,通过export DERBY_HOME=$JAVA_HOME/db为 Unix (Korn shell)设置这个变量。

images 注意嵌入式和客户端/服务器环境设置脚本引用了一个DERBY_INSTALL环境变量。根据“Re: DERBY_INSTALL 和 DERBY_HOME”邮件项([www.mail-archive.com/derby-dev@db.apache.org/msg22098.html](http://www.mail-archive.com/derby-dev@db.apache.org/msg22098.html) ), DERBY_HOME等同于并取代DERBY_INSTALL,以与其他 Apache 项目保持一致。

您还必须设置CLASSPATH环境变量。设置该环境变量最简单的方法是运行 Java DB 附带的脚本文件。Windows 和 Unix/Linux 版本的各种“set*xxx*CP”脚本文件(扩展了当前的类路径)位于%JAVA_HOME%\db \ bin目录中。要运行的脚本文件将取决于您是使用嵌入式环境还是客户端/服务器环境:

  • 对于嵌入式环境,调用setEmbeddedCPderby.jarderbytools.jar添加到类路径中。
  • 对于客户机/服务器环境,调用setNetworkServerCPderbynet.jarderbytools.jar添加到类路径中。在单独的命令窗口中,调用setNetworkClientCPderbyclient.jarderbytools.jar添加到类路径中。

images 注意Windows 的setEmbeddedCP.batsetNetworkClientCP.batsetNetworkServerCP.bat文件有问题。每个文件的@FOR %%X in ("%DERBY_HOME%") DO SET DERBY_HOME=%%~sX行搞乱了CLASSPATH环境变量——我认为问题与~sX有关。我发现注释掉这一行(通过在这一行前面加上rem和一个空格)可以解决这个问题。

Java DB 演示

%JAVA_HOME%\demo\db\programs目录包含描述 Java DB 中包含的演示的 HTML 文档;demo.html文件是这个文档的入口点。这些演示包括一个使用 Java DB 的简单 JDBC 应用、一个网络服务器示例程序,以及在使用 Derby 手册中介绍的示例程序。

images 注意使用 Derby 手册强调了 Java DB 的 Derby 传统。您可以从 Apache 的 Derby 项目网站([db.apache.org/derby/index.html](http://db.apache.org/derby/index.html))的文档部分([db.apache.org/derby/manuals/index.html](http://db.apache.org/derby/manuals/index.html))下载本手册和其他 Derby 手册。

为了简洁起见,我将只关注位于programs目录的simple子目录中的简单的 JDBC 应用。该应用运行在默认的嵌入式环境或客户机/服务器环境中。它创建并连接到一个derbyDB数据库,在这个数据库中引入一个表,在这个表上执行插入/更新/选择操作,丢弃(删除)这个表,并从数据库断开连接。

要在嵌入式环境中运行该应用,请打开一个命令窗口,并确保已经正确设置了DERBY_HOMECLASSPATH环境变量;调用setEmbeddedCP来设置类路径。假设simple是当前目录,调用java SimpleAppjava SimpleApp embedded来运行这个应用。您应该观察到以下输出:

SimpleApp starting in embedded mode
Loaded the appropriate driver
Connected to and created database derbyDB
Created table location
Inserted 1956 Webster
Inserted 1910 Union
Updated 1956 Webster to 180 Grand
Updated 180 Grand to 300 Lakeshore
Verified the rows
Dropped table location
Committed the transaction
Derby shut down normally
SimpleApp finished

该输出表明,在嵌入式环境中运行的应用在退出之前关闭了数据库引擎。这样做是为了执行检查点并释放资源。当这个关闭没有发生时,Java DB 注意到检查点的缺失,假设崩溃,并导致恢复代码在下一个数据库连接之前运行(这需要更长的时间来完成)。

images 提示在嵌入式环境中运行SimpleApp(或任何其他 Java DB 应用)时,可以通过设置derby.system.home属性来确定数据库目录将创建在哪里。例如,java -Dderby.system.home=c:\ SimpleApp导致在 Windows 平台上的 C:驱动器的根目录中创建derbyDB

要在客户机/服务器环境中运行该应用,您需要启动网络服务器,并在单独的命令窗口中运行该应用。

在一个命令窗口中,设置DERBY_HOME。通过startNetworkServer脚本(位于%JAVA_HOME%\db \ bin)启动网络服务器,它负责设置类路径。您应该会看到类似如下的输出:

Mon Sep 19 21:23:14 CDT 2011 : Security manager installed using the Basic server![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) security
policy.
Mon Sep 19 21:23:16 CDT 2011 : Apache Derby Network Server - 10.8.1.2 - (1095077)![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) started
and ready to accept connections on port 1527

在另一个命令窗口中,设置DERBY_HOME,然后设置CLASSPATH(通过setNetworkClientCP)。假设simple是当前目录,调用java SimpleApp derbyClient来运行这个应用。这一次,您应该观察到以下输出:

SimpleApp starting in derbyclient mode
Loaded the appropriate driver
Connected to and created database derbyDB
Created table location
Inserted 1956 Webster
Inserted 1910 Union
Updated 1956 Webster to 180 Grand
Updated 180 Grand to 300 Lakeshore
Verified the rows
Dropped table location
Committed the transaction
SimpleApp finished

请注意,在客户机/服务器环境中,数据库引擎没有关闭。虽然在输出中没有指出,但是在嵌入式和客户机/服务器环境中运行SimpleApp还有第二个区别。在嵌入式环境中,derbyDB数据库目录是在simple目录中创建的。在客户机/服务器环境中,这个数据库目录是在执行startNetworkServer时的当前目录中创建的。

当您在客户机/服务器环境中使用完SimpleApp后,您应该关闭网络服务器和数据库引擎。通过调用stopNetworkServer脚本(位于%JAVA_HOME%\db \ bin)来完成这个任务。您还可以通过运行NetworkServerControl脚本(也位于%JAVA_HOME%\db \ bin)来关闭(或启动并控制)网络服务器。例如,NetworkServerControl shutdown关闭网络服务器和数据库引擎。

Java DB 命令行工具

%JAVA_HOME%\db \ bin目录包含用于启动命令行工具的sysinfoijdblook Windows 和 Unix/Linux 脚本文件:

  • 运行sysinfo查看 Java 环境/Java DB 配置。
  • 运行ij来运行执行特定 SQL 命令和重复任务的脚本。
  • 运行dblook查看数据库的全部或部分数据定义语言(DDL)。

如果您在使用 Java DB 时遇到问题(例如,无法连接到数据库),您可以运行sysinfo来找出问题是否与配置有关。该工具在 Java 信息、Derby 信息和语言环境信息标题下报告各种设置——我在附录 c 中讨论了语言环境。

`------------------ Java Information ------------------
Java Version:    1.7.0
Java Vendor:     Oracle Corporation
Java home:       C:\Program Files\Java\jdk1.7.0\jre
Java classpath:  C:\Program Files\Java\jdk1.7.0\db\lib\derby.jar;C:\Programimages
Files\Java\jdk1.7.0\db\lib\derbytools.jar;;C:\Programimages
Files\Java\jdk1.7.0\db/lib/derby.jar;C:\Programimages
Files\Java\jdk1.7.0\db/lib/derbynet.jar;C:\Programimages
Files\Java\jdk1.7.0\db/lib/derbyclient.jar;C:\Programimages
Files\Java\jdk1.7.0\db/lib/derbytools.jar
OS name:         Windows XP
OS architecture: x86
OS version:      5.1
Java user name:  Jeff Friesen
Java user home:  C:\Documents and Settings\Jeff Friesen
Java user dir:   C:\PROGRA1\Java\JDK171.0\db\bin
java.specification.name: Java Platform API Specification
java.specification.version: 1.7
java.runtime.version: 1.7.0-b147
--------- Derby Information --------
JRE - JDBC: Java SE 6 - JDBC 4.0
[C:\Program Files\Java\jdk1.7.0\db\lib\derby.jar] 10.8.1.2 - (1095077)
[C:\Program Files\Java\jdk1.7.0\db\lib\derbytools.jar] 10.8.1.2 - (1095077)
[C:\Program Files\Java\jdk1.7.0\db\lib\derbynet.jar] 10.8.1.2 - (1095077)
[C:\Program Files\Java\jdk1.7.0\db\lib\derbyclient.jar] 10.8.1.2 - (1095077)

----------------- Locale Information -----------------
Current Locale :  [English/United States [en_US]]
Found support for locale: [cs]
         version: 10.8.1.2 - (1095077)
Found support for locale: [de_DE]
         version: 10.8.1.2 - (1095077)
Found support for locale: [es]
         version: 10.8.1.2 - (1095077)
Found support for locale: [fr]
         version: 10.8.1.2 - (1095077) Found support for locale: [hu]
         version: 10.8.1.2 - (1095077)
Found support for locale: [it]
         version: 10.8.1.2 - (1095077)
Found support for locale: [ja_JP]
         version: 10.8.1.2 - (1095077)
Found support for locale: [ko_KR]
         version: 10.8.1.2 - (1095077)
Found support for locale: [pl]
         version: 10.8.1.2 - (1095077)
Found support for locale: [pt_BR]
         version: 10.8.1.2 - (1095077)
Found support for locale: [ru]
         version: 10.8.1.2 - (1095077)
Found support for locale: [zh_CN]
         version: 10.8.1.2 - (1095077)
Found support for locale: [zh_TW]
         version: 10.8.1.2 - (1095077)
------------------------------------------------------`

通过运行指定适当 DDL 语句的脚本文件,ij脚本对于创建数据库和初始化用户的模式(逻辑组织表和其他数据库对象的名称空间)非常有用。例如,您创建了一个包含NAMEPHOTO列的EMPLOYEES表,并在当前目录中创建了一个包含以下行的create_emp_schema.sql脚本文件:

CREATE TABLE EMPLOYEES(NAME VARCHAR(30), PHOTO BLOB);

下面的嵌入式ij脚本会话创建了employees数据库和EMPLOYEES表:

C:\db>ij
ij version 10.8
ij> connect 'jdbc:derby:employees;create=true';
ij> run 'create_emp_schema.sql';
ij> CREATE TABLE EMPLOYEES(NAME VARCHAR(30), PHOTO BLOB);
0 rows inserted/updated/deleted
ij> disconnect;
ij> exit;
C:>\db>

connect命令导致employees数据库被创建——当我在本章后面介绍 JDBC 时,我会对这个命令的语法说得更多。run命令导致create_emp_schema.sql执行,结果生成后续的一对线。

CREATE TABLE EMPLOYEES(NAME VARCHAR(30), PHOTO BLOB);行是一个 SQL 语句,用于创建一个名为EMPLOYEES的表,表中有NAMEPHOTO列。输入到NAME列中的数据项属于 SQL 类型VARCHAR(可变数量的字符—一个字符串),最多有30个字符,输入到PHOTO列中的数据项属于 SQL 类型BLOB(一个二进制大对象,如图像)。

images 注意我用大写指定 SQL 语句,但你也可以用小写或大小写混合指定。

run 'create_emp_schema.sql'结束后,指定的EMPLOYEES表被添加到新创建的employees数据库中。要验证该表是否存在,请对employees目录运行dblook,如下面的会话所示。

C:\db>dblook -d jdbc:derby:employees
-- Timestamp: 2011-09-19 22:17:20.375
-- Source database is: employees
-- Connection URL is: jdbc:derby:employees
-- appendLogs: false

-- ----------------------------------------------
-- DDL Statements for tables
-- ----------------------------------------------

CREATE TABLE "APP"."EMPLOYEES" ("NAME" VARCHAR(30), "PHOTO" BLOB(2147483647));

C:\db>

所有数据库对象(例如,表和索引)都被分配给用户和系统模式,这些模式以包逻辑组织类的相同方式逻辑组织这些对象。当用户创建或访问数据库时,Java DB 使用指定的用户名作为新添加的数据库对象的名称空间名称。在没有用户名的情况下,Java DB 选择APP,如前面的会话输出所示。

JDBC

JDBC 是一个 API(与java.sqljavax.sqljavax.sql.rowsetjavax.sql.rowset.serialjavax.sql.rowset.spi包相关联——本章我主要关注java.sql),用于以独立于 RDBMS 的方式与 RDBMS 通信。您可以使用 JDBC 来执行各种数据库操作,例如提交 SQL 语句,告诉 RDBMS 创建关系数据库或表,以及更新或查询表格数据。

images 注意 Java 7 支持 JDBC 4.1。关于 JDBC 4.1 特有的特性列表,请查看[download.oracle.com/javase/7/docs/technotes/guides/jdbc/jdbc_41.html](http://download.oracle.com/javase/7/docs/technotes/guides/jdbc/jdbc_41.html)

数据源、驱动程序和连接

尽管 JDBC 通常用于与 RDBMSes 通信,但它也可用于与平面文件数据库通信。出于这个原因,JDBC 使用术语数据源(一种由 RDBMS 管理的从简单文件到复杂关系数据库的数据存储设施)来抽象数据源。

因为访问数据源的方式不同(例如,第八章的平面文件数据库是通过java.io.RandomAccessFile类的方法访问的,而 Java DB 数据库是通过 SQL 语句访问的),JDBC 使用驱动程序(类文件插件)来抽象它们的实现。这种抽象使您可以编写一个应用,它可以适应任意数据源,而不必更改一行代码(在大多数情况下)。驱动程序是java.sql.Driver接口的实现。

JDBC 认可四种类型的司机:

  • 类型 1 驱动程序将 JDBC 实现为到另一个数据访问 API 的映射(例如,开放式数据库连接,或 ODBC——参见[en.wikipedia.org/wiki/ODBC](http://en.wikipedia.org/wiki/ODBC))。驱动程序将 JDBC 方法调用转换成另一个库的函数调用。JDBC-ODBC 桥驱动程序就是一个例子,它不受 Oracle 支持。在 JDBC 早期,当其他种类的司机不常见时,这种方法很常用。
  • Type 2 驱动部分用 Java 编写,部分用原生代码编写(见附录 C)。它们与特定于数据源的本机客户端库进行交互,因此不可移植。Oracle 的 OCI (Oracle 调用接口)客户端驱动程序就是一个例子。
  • Type 3 驱动不依赖于本地代码,通过 RDBMS 独立协议与中间件服务器(位于应用客户端和数据源之间的服务器)通信。然后,中间件服务器将客户机的请求传递给数据源。
  • Type 4 驱动不依赖于本机代码,实现特定数据源的网络协议。客户端直接连接到数据源,而不是通过中间件服务器。

在与数据源通信之前,您需要建立连接。JDBC 为此提供了java.sql.DriverManager类和javax.sql.DataSource接口:

  • 让应用通过指定 URL 连接到数据源。当该类首次尝试建立连接时,它会自动加载通过类路径找到的任何 JDBC 4.x 驱动程序。(JDBC 4.x 之前版本的驱动程序必须手动加载。)
  • DataSource对应用隐藏连接细节,以提高数据源的可移植性,因此比DriverManager更受欢迎。因为对DataSource的讨论有点复杂(并且通常在 Java EE 环境中使用),所以我在本章中重点讨论DriverManager

在让您获得数据源连接之前,早期的 JDBC 版本要求您通过用实现Driver接口的类名指定Class.forName()来显式加载合适的驱动程序。例如,JDBC-ODBC 桥驱动程序是通过Class.forName("sun.jdbc.odbc.JdbcOdbcDriver");加载的。后来的 JDBC 版本放宽了这一要求,允许您通过jdbc.drivers系统属性指定要加载的驱动程序列表。DriverManager会在初始化时尝试加载所有这些驱动程序。

在 Java 7 下,DriverManager首先加载由jdbc.drivers系统属性标识的所有驱动程序。然后,它使用基于java.util.ServiceLoader的服务提供者机制(在附录 C 中讨论)从可访问的驱动程序 JAR 文件中加载所有驱动程序,这样您就不必显式地加载驱动程序。这种机制需要将驱动程序打包到一个包含META-INF/services/java.sql.Driver的 JAR 文件中。java.sql.Driver文本文件必须包含一行来命名驱动程序对Driver接口的实现。

每个加载的驱动程序通过DriverManagervoid registerDriver(Driver driver)类方法实例化并向DriverManager注册自己。当被调用时,getConnection()方法遍历注册的驱动程序,从识别getConnection()的 JDBC URL 的第一个驱动程序返回java.sql.Connection接口的实现。(你可能想看看DriverManager的源代码,看看这是怎么做到的。)

images 注意为了保持数据源的独立性,大部分 JDBC 由接口组成。每个驱动程序都提供了各种接口的实现。

要连接到数据源并获得一个Connection实例,调用DriverManagerConnection getConnection(String url)Connection getConnection(String url, Properties info)Connection getConnection(String url, String user, String password)方法之一。无论使用哪种方法,url参数都会指定一个基于字符串的 URL,该 URL 以前缀jdbc:开头,并以特定于数据源的语法结尾。

考虑 Java DB。URL 语法因驱动程序而异。对于嵌入式驱动程序(当您想要访问本地数据库时),语法如下:

jdbc:derby:*databaseName*;*URLAttributes*

对于客户端驱动程序(当您想要访问远程数据库时,尽管您也可以使用该驱动程序访问本地数据库),语法如下:

jdbc:derby://*host*:*port*/*databaseName*;*URLAttributes*

无论使用哪种语法, URLAttributes 都是分号分隔的 name =*value*对的可选序列。例如,create=true告诉 Java DB 创建一个新的数据库。

以下示例演示了第一种语法,告诉 JDBC 加载 Java DB 嵌入式驱动程序,并在本地主机上创建名为testdb的数据库:

Connection con = DriverManager.getConnection("jdbc:derby:testdb;create=true");

下面的例子演示了第二种语法,告诉 JDBC 加载 Java DB 客户机驱动程序,并在xyz主机的端口8500上创建名为testdb的数据库:

Connection con;
con = DriverManager.getConnection("jdbc:derby://xyz:8500/testdb;create=true");

images 为了方便起见,本章的应用只使用嵌入式驱动程序连接语法。

例外情况

DriverManagergetConnection()方法(以及各种 JDBC 接口中的其他 JDBC 方法)在出错时抛出java.sql.SQLException或它的一个子类。除了从java.lang.Throwable(例如String getMessage())继承的方法之外,SQLException还声明了各种构造函数(为了简洁起见,不再讨论)和以下方法:

  • int getErrorCode()返回特定于供应商的整数错误代码。通常,该值将是基础数据源返回的实际错误代码。
  • SQLException getNextException()返回链接到这个SQLException对象的SQLException实例(通过调用setNextException(SQLException ex)),或者当没有链接的异常时返回 null。
  • String getSQLState()返回一个“SQLstate”字符串,该字符串提供了标识异常的 X/Open 或 SQL:2003 错误代码。
  • Iterator<Throwable> iterator()以适当的顺序返回链式SQLException及其原因的迭代器。迭代器将用于迭代每个SQLException及其潜在原因(如果有的话)。你通常不会调用这个方法,但是当你需要迭代SQLException的链时,你会使用增强的 for 语句(在第五章中讨论过)来调用iterator()
  • void setNextException(SQLException sqlex)sqlex追加到链的末尾。

在处理请求时可能会出现一个或多个SQLException,抛出这些异常的代码可以通过调用setNextException()将它们添加到SQLException中。此外,一个SQLException实例可能作为一个不同异常(例如IOException)的结果被抛出,这被称为该异常的原因(参见第三章)。

SQL 状态错误代码由 ISO/ANSI 和开放组(X/Open) SQL 标准定义。错误代码是一个 5 个字符的字符串,由 2 个字符的类值和 3 个字符的子类值组成。类值“00”表示成功,类值“01”表示警告,其他类值通常表示异常。SQL 状态错误代码的示例有 00000(成功)和 08001(无法连接到数据源)。

以下示例向您展示了如何构建您的应用,以连接到 Java DB 数据源,执行一些工作,并响应抛出的SQLException实例:

String url = "jdbc:derby:employee;create=true";
try (Connection con = DriverManager.getConnection(url))
{
   // Perform useful work. The following throw statement simulates a
   // JDBC method throwing SQLException.
   throw new SQLException("Unable to access database table",
                          new java.io.IOException("File I/O problem"));
}
catch (SQLException sqlex)
{
   while (sqlex != null)
   {
      System.err.println("SQL error : "+sqlex.getMessage());
      System.err.println("SQL state : "+sqlex.getSQLState());
      System.err.println("Error code: "+sqlex.getErrorCode());
      System.err.println("Cause: "+sqlex.getCause());
      sqlex = sqlex.getNextException();
   }
}

不再需要时,必须关闭连接;Connection为此声明了一个void close()方法。因为Connection实现了java.lang.AutoCloseable,你可以使用 try-with-resources 语句(参见第三章)让这个方法自动被调用,不管是否抛出异常。

假设还没有配置 Java DB(通过设置DERBY_HOMECLASSPATH环境变量),您应该会看到下面的输出:

SQL error : No suitable driver found for jdbc:derby:employee;create=true SQL state : 08001 Error code: 0 Cause: null

如果您已经配置了 Java DB,您应该观察不到任何输出。

SQLException声明了几个子类(如java.sql.BatchUpdateException—批量更新操作过程中出现错误)。这些子类中的许多被归类在java.sql.SQLNonTransientExceptionjava.sql.SQLTransientException根类层次结构下,其中SQLNonTransientException描述了在不改变应用源代码或数据源的某些方面的情况下不能重试的失败操作,而SQLTransientException描述了可以立即重试的失败操作。

报表

在获得到数据源的连接之后,应用通过发出 SQL 语句(例如,CREATE TABLEINSERTSELECTUPDATEDELETEDROP TABLE)与数据源进行交互。JDBC 通过java.sql.Statementjava.sql.PreparedStatementjava.sql.CallableStatement接口支持 SQL 语句。此外,Connection声明了各种createStatement()prepareStatementprepareCall()方法,分别返回StatementPreparedStatementCallableStatement实现实例。

语句和结果集

Statement是最简单易用的接口,ConnectionStatement createStatement()方法是最简单易用的获取Statement实例的方法。调用此方法后,您可以通过调用如下的Statement方法来执行各种 SQL 语句:

  • ResultSet executeQuery(String sql)执行一个SELECT语句,并(假设没有抛出异常)通过一个java.sql.ResultSet实例提供对其结果的访问。
  • int executeUpdate(String sql)执行一个CREATE TABLEINSERTUPDATEDELETEDROP TABLE语句,并且(假设没有抛出异常)通常返回受该语句影响的表行数。

我创建了一个EmployeeDB应用来演示这些方法。清单 9-17 展示了它的源代码。

清单 9-17。创建、插入值、查询和删除EMPLOYEES

`import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

class EmployeeDB
{
   public static void main(String[] args)
   {
      String url = "jdbc:derby:employee;create=true";       try (Connection con = DriverManager.getConnection(url))
      {
         try (Statement stmt = con.createStatement())
         {
            String sql = "CREATE TABLE EMPLOYEES(ID INTEGER, NAME VARCHAR(30))";
            stmt.executeUpdate(sql);
            sql = "INSERT INTO EMPLOYEES VALUES(1, 'John Doe')";
            stmt.executeUpdate(sql);
            sql = "INSERT INTO EMPLOYEES VALUES(2, 'Sally Smith')";
            stmt.executeUpdate(sql);
            ResultSet rs = stmt.executeQuery("SELECT * FROM EMPLOYEES");
            while (rs.next())
               System.out.println(rs.getInt("ID")+" "+rs.getString("NAME"));
            sql = "DROP TABLE EMPLOYEES";
            stmt.executeUpdate(sql);
         }
      }
      catch (SQLException sqlex)
      {
         while (sqlex != null)
         {
            System.err.println("SQL error : "+sqlex.getMessage());
            System.err.println("SQL state : "+sqlex.getSQLState());
            System.err.println("Error code: "+sqlex.getErrorCode());
            System.err.println("Cause: "+sqlex.getCause());
            sqlex = sqlex.getNextException();
         }
      }
   }
}`

成功建立到employee数据源的连接后,main()创建一个语句,并使用它来执行 SQL 语句,以创建、插入值、查询和删除一个EMPLOYEES表。

executeQuery()方法返回一个ResultSet对象,该对象提供对查询的表格结果的访问。每个结果集都与一个光标相关联,该光标提供对特定数据行的访问。光标最初指向第一行之前;调用ResultSetboolean next()方法将光标移到下一行。只要有下一行,这个方法就返回 true 当没有更多的行要检查时,它返回 false。

ResultSet还声明了基于类型返回当前行的列值的各种方法。例如,int getInt(String columnLabel)返回由columnLabel标识的基于INTEGER的列对应的整数值。类似地,String getString(String columnLabel)返回对应于由columnLabel标识的基于VARCHAR的列的字符串值。

images 提示如果没有列名但是有从零开始的列索引,那么调用int getInt(int columnIndex)``String getString(int columnIndex)ResultSet方法。然而,最佳实践是调用int getInt(String columnLabel)

编译清单 9-17 ( javac EmployeeDB.java)并运行这个应用(java EmployeeDB)。您应该观察到以下输出:

1 John Doe
2 Sally Smith

SQL 的INTEGERVARCHAR类型映射到 Java 的intString类型。表 9-1 给出了一个更完整的类型映射列表。

images

images

准备报表

PreparedStatement是下一个最容易使用的接口,ConnectionPreparedStatement prepareStatement()方法是获得PreparedStatement实例最容易使用的方法——PreparedStatementStatement的子接口。

与常规语句不同,预处理语句代表预编译的 SQL 语句。SQL 语句被编译以提高性能并防止 SQL 注入(参见[en.wikipedia.org/wiki/SQL_injection](http://en.wikipedia.org/wiki/SQL_injection)),编译结果存储在PreparedStatement实现实例中。

当您希望多次执行同一个准备好的语句时(例如,您希望多次执行 SQL INSERT语句来填充数据库表),通常会获得此实例。考虑以下示例:

sql = "INSERT INTO EMPLOYEES VALUES(?, ?)";
try (PreparedStatement pstmt = con.prepareStatement(sql))
{
   String[] empNames = {"John Doe", "Sally Smith"};
   for (int i = 0; i < empNames.length; i++)
   {
      pstmt.setInt(1, i+1);
      pstmt.setString(2, empNames[i]);
      pstmt.executeUpdate();
   }
}

这个例子首先创建一个指定 SQL INSERT语句的String对象。每个“?”字符都充当一个占位符,用于在执行语句之前指定一个值。

在获得了PreparedStatement实现实例之后,这个接口的void setInt(int parameterIndex, int x)void setString(int parameterIndex, String x)方法在这个实例上被调用来提供这些值(传递给每个方法的第一个参数是一个从 1 开始的整数列索引,该列索引到与语句相关的表中——1 对应于最左边的列),然后PreparedStatementint executeUpdate()方法被调用来执行这个 SQL 语句。最终结果是:包含John DoeSally Smith的一对行以及它们各自的标识符被添加到EMPLOYEES表中。

可调用语句

CallableStatement是最专业的语句接口;它延伸了PreparedStatement。您使用这个接口来执行 SQL 存储过程,其中存储过程是执行特定任务(例如,解雇一名员工)的 SQL 语句列表。Java DB 与其他 RDBMSes 的不同之处在于,存储过程的主体被实现为一个public static Java 方法。此外,声明该方法的类必须是public

您可以通过执行一条 SQL 语句来创建一个存储过程,这条语句通常以CREATE PROCEDURE开始,然后以 RDBMS 特定的语法继续。例如,用于创建存储过程的 Java DB 语法,如在[db.apache.org/derby/docs/dev/ref/rrefcreateprocedurestatement.html](http://db.apache.org/derby/docs/dev/ref/rrefcreateprocedurestatement.html)的网页上所指定的,如下所示:

CREATE PROCEDURE *procedure-name* ([ *procedure-parameter* [, *procedure-parameter* ] ]*)
[ *procedure-element* ]*

procedure-name 表示为

[ *schemaName* .] *SQL92Identifier*

procedure-parameter 表示为

[{ IN | OUT | INOUT }] [ *parameter-Name* ] *DataType*

procedure-element 表示为

{
| [ DYNAMIC ] RESULT SETS INTEGER
| LANGUAGE { JAVA }
| *DeterministicCharacteristic*
| EXTERNAL NAME *string*
| PARAMETER STYLE JAVA
| EXTERNAL SECURITY { DEFINER | INVOKER }
| { NO SQL | MODIFIES SQL DATA | CONTAINS SQL | READS SQL DATA }
}

[]之间的任何内容都是可选的,[]右边的*表示这些元字符之间的任何内容可以出现零次或多次,{}元字符包围一个项目列表,|分隔可能的项目——只能指定其中一个项目。

例如,CREATE PROCEDURE FIRE(IN ID INTEGER) PARAMETER STYLE JAVA LANGUAGE JAVA DYNAMIC RESULT SETS 0 EXTERNAL NAME 'EmployeeDB.fire'创建了一个名为FIRE的存储过程。这个过程指定了一个名为ID的输入参数,并与名为EmployeeDBpublic类中名为firepublic static方法相关联。

创建存储过程后,您需要获得一个CallableStatement实现实例来调用该过程,您可以通过调用ConnectionprepareCall()方法之一来实现;比如CallableStatement prepareCall(String sql)

传递给prepareCall()的字符串是一个转义子句(独立于 RDBMS 的语法),包括一个开始的{,后面是单词call,再后面是一个空格,再后面是存储过程的名称,再后面是一个参数列表,其中带有用于将要传递的参数的占位符?,再后面是一个结束的}

images 注意 Escape 子句是 JDBC 消除不同 RDBMS 供应商实现 SQL 方式差异的方法。当 JDBC 驱动程序检测到转义语法时,它会将其转换成特定 RDBMS 能够理解的代码。这使得转义语法与 RDBMS 无关。

一旦有了一个CallableStatement引用,就可以像使用PreparedStatement一样将参数传递给这些参数。以下示例演示了:

try (CallableStatement cstmt = con.prepareCall("{ call FIRE(?)}"))
{
   cstmt.setInt(1, 2);
   cstmt.execute();
}

cstmt.setInt(1, 2)方法调用将 2 赋给最左边的存储过程参数——参数索引1对应于最左边的参数(或者当只有一个参数时对应于单个参数)。cstmt.execute()方法调用执行存储过程,这导致对应用的public static void fire(int id)方法的回调。

我已经创建了另一个版本的EmployeeDB应用来演示这个可调用语句。清单 9-18 展示了它的源代码。

清单 9-18。通过存储过程解雇员工

`import java.sql.CallableStatement;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

public class EmployeeDB
{
   public static void main(String[] args)
   {
      String url = "jdbc:derby:employee;create=true";
      try (Connection con = DriverManager.getConnection(url))
      {
         try (Statement stmt = con.createStatement())
         {
            String sql = "CREATE PROCEDURE FIRE(IN ID INTEGER)"+
                         "   PARAMETER STYLE JAVA"+
                         "   LANGUAGE JAVA"+
                         "   DYNAMIC RESULT SETS 0"+
                         "   EXTERNAL NAME 'EmployeeDB.fire'";
            stmt.executeUpdate(sql);
            sql = "CREATE TABLE EMPLOYEES(ID INTEGER, NAME VARCHAR(30), "+
                                          "FIRED BOOLEAN)";
            stmt.executeUpdate(sql);
            sql = "INSERT INTO EMPLOYEES VALUES(1, 'John Doe', false)";             stmt.executeUpdate(sql);
            sql = "INSERT INTO EMPLOYEES VALUES(2, 'Sally Smith', false)";
            stmt.executeUpdate(sql);
            dump(stmt.executeQuery("SELECT * FROM EMPLOYEES"));
            try (CallableStatement cstmt = con.prepareCall("{ call FIRE(?)}"))
            {
               cstmt.setInt(1, 2);
               cstmt.execute();
            }
            dump(stmt.executeQuery("SELECT * FROM EMPLOYEES"));
            sql = "DROP TABLE EMPLOYEES";
            stmt.executeUpdate(sql);
            sql = "DROP PROCEDURE FIRE";
            stmt.executeUpdate(sql);
         }
      }
      catch (SQLException sqlex)
      {
         while (sqlex != null)
         {
            System.err.println("SQL error : "+sqlex.getMessage());
            System.err.println("SQL state : "+sqlex.getSQLState());
            System.err.println("Error code: "+sqlex.getErrorCode());
            System.err.println("Cause: "+sqlex.getCause());
            sqlex = sqlex.getNextException();
         }
      }
   }
   static void dump(ResultSet rs) throws SQLException
   {
      while (rs.next())
         System.out.println(rs.getInt("ID")+" "+rs.getString("NAME")+
                            " "+rs.getBoolean("FIRED"));
      System.out.println();
   }
   public static void fire(int id) throws SQLException
   {
      Connection con = DriverManager.getConnection("jdbc:default:connection");
      String sql = "UPDATE EMPLOYEES SET FIRED=TRUE WHERE ID="+id;
      try (Statement stmt = con.createStatement())
      {
         stmt.executeUpdate(sql);
      }
   }
}`

这个清单的大部分内容应该很容易理解,所以我将只讨论fire()方法。如前所述,这个方法是调用 callable 语句的结果。

用雇员的整数标识符调用fire()来触发。它首先通过使用jdbc.default:connection参数调用getConnection()来访问当前的Connection对象,这是由 Oracle JVMs 通过一个特殊的内部驱动程序支持的。

在创建一个 SQL UPDATE语句字符串以将EMPLOYEES表行中的FIRED列设置为 true(其 ID 字段等于id中的值)之后,fired()调用executeUpdate()来适当地更新该表。

编译清单 9-18 ( javac EmployeeDB.java)并运行这个应用(java EmployeeDB)。您应该观察到以下输出:

1 John Doe false
2 Sally Smith false

1 John Doe false
2 Sally Smith true
元数据

数据源通常与描述数据源的元数据(关于数据的数据)相关联。当数据源是 RDBMS 时,该数据通常存储在表的集合中。

元数据包括一列目录 (RDBMS 数据库,其表描述 RDBMS 对象,如基表【物理存在的表】、视图【虚拟表】、索引【提高数据检索操作速度的文件】)、模式(对数据库对象进行分区的名称空间),以及附加信息(如版本号、标识字符串和限制)。

要访问数据源的元数据,调用ConnectionDatabaseMetaData getMetaData()方法。这个方法返回一个java.sql.DatabaseMetaData接口的实现实例。

我已经创建了一个演示了getMetaData()和各种DatabaseMetaData方法的MetaData应用。清单 9-19 展示了MetaData的源代码。

清单 9-19。employee数据源获取元数据

`import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

class MetaData
{
   public static void main(String[] args)
   {
      String url = "jdbc:derby:employee;create=true";
      try (Connection con = DriverManager.getConnection(url))
      {
         try (Statement stmt = con.createStatement())
         {
            dump(con.getMetaData());
         }
      }
      catch (SQLException sqlex)
      {
         while (sqlex != null)
         {
            System.err.println("SQL error : "+sqlex.getMessage());             System.err.println("SQL state : "+sqlex.getSQLState());
            System.err.println("Error code: "+sqlex.getErrorCode());
            System.err.println("Cause: "+sqlex.getCause());
            sqlex = sqlex.getNextException();
         }
      }
   }
   static void dump(DatabaseMetaData dbmd) throws SQLException
   {
      System.out.println("DB Major Version = "+dbmd.getDatabaseMajorVersion());
      System.out.println("DB Minor Version = "+dbmd.getDatabaseMinorVersion());
      System.out.println("DB Product = "+dbmd.getDatabaseProductName());
      System.out.println("Driver Name = "+dbmd.getDriverName());
      System.out.println("Numeric function names for escape clause = "+
                         dbmd.getNumericFunctions());
      System.out.println("String function names for escape clause = "+
                         dbmd.getStringFunctions());
      System.out.println("System function names for escape clause = "+
                         dbmd.getSystemFunctions());
      System.out.println("Time/date function names for escape clause = "+
                         dbmd.getTimeDateFunctions());
      System.out.println("Catalog term: "+dbmd.getCatalogTerm());
      System.out.println("Schema term: "+dbmd.getSchemaTerm());
      System.out.println();
      System.out.println("Catalogs");
      System.out.println("--------");
      ResultSet rsCat = dbmd.getCatalogs();
      while (rsCat.next())
         System.out.println(rsCat.getString("TABLE_CAT"));
      System.out.println();
      System.out.println("Schemas");
      System.out.println("-------");
      ResultSet rsSchem = dbmd.getSchemas();
      while (rsSchem.next())
         System.out.println(rsSchem.getString("TABLE_SCHEM"));
      System.out.println();
      System.out.println("Schema/Table");
      System.out.println("------------");
      rsSchem = dbmd.getSchemas();
      while (rsSchem.next())
      {
         String schem = rsSchem.getString("TABLE_SCHEM");
         ResultSet rsTab = dbmd.getTables(null, schem, "%", null);
         while (rsTab.next())
            System.out.println(schem+" "+rsTab.getString("TABLE_NAME"));
      }
   }
}`

清单 9-19 的dump()方法调用其dbmd参数上的各种方法来输出分类元数据。

int getDatabaseMajorVersion()int getDatabaseMinorVersion()方法返回 Java DB 版本号的主要部分(例如 10)和次要部分(例如 8)。同样,String getDatabaseProductName()返回该产品的名称(如 Apache Derby),而String getDriverName()返回驱动程序的名称(如 Apache Derby 嵌入式 JDBC 驱动程序)。

SQL 定义了各种函数,这些函数可以作为SELECT和其他语句的一部分被调用。例如,您可以指定SELECT COUNT(*) AS TOTAL FROM EMPLOYEES来返回一行一列的结果集,其中列名为TOTAL,行值包含EMPLOYEES表中的行数。

因为不是所有的 RDM 都采用相同的语法来指定函数调用,所以 JDBC 使用了一个由{ fn *functionname*(*arguments*) }组成的函数转义子句,来抽象不同之处。例如,SELECT {fn UCASE(NAME)} FROM EMPLOYEESEMPLOYEES中选择所有的NAME列值,并在结果集中大写它们的值。

String getNumericFunctions()String getStringFunctions()String getSystemFunctions()String getTimeDateFunctions()方法返回可能出现在函数转义子句中的函数名列表。例如,getNumericFunctions()为 Java DB 10.8 返回ABS,ACOS,ASIN,ATAN,ATAN2,CEILING,COS,COT,DEGREES,EXP,FLOOR,LOG,LOG10,MOD,PI,RADIANS,RAND,SIGN,SIN,SQRT,TAN

并非所有供应商都使用相同的术语来描述目录和模式。出于这个原因,String getCatalogTerm()String getSchemaTerm()方法用于返回特定于供应商的术语,对于 Java DB 10.8 来说恰好是CATALOGSCHEMA

ResultSet getCatalogs()方法返回目录名的结果集,可通过结果集的TABLE_CAT列访问。对于 Java DB 10.8,这个结果集是空的,Java DB 10.8 将单个默认目录划分为不同的模式。

ResultSet getSchemas()方法返回模式名的结果集,可以通过结果集的TABLE_SCHEM列访问。该列包含 Java DB 10.8 的APPNULLIDSQLJSYSSYSCATSYSCS_DIAGSYSCS_UTILSYSFUNSYSIBMSYSPROCSYSSTAT值。APP是存储用户数据库对象的默认模式。

ResultSet getTables(String catalog, String schemaPattern, String tableNamePattern, String[] types)方法返回一个结果集,其中包含表名(在TABLE_NAME列中)和其他与指定的catalogschemaPatterntableNamePatterntypes匹配的面向表的元数据。为了获得特定模式的所有表的结果集,将null传递给catalogtypes,将模式名传递给schemaPattern,将%通配符传递给tableNamePattern

例如,SYS模式存储了SYSALIASESSYSCHECKSSYSCOLPERMSSYSCOLUMNSSYSCONGLOMERATESSYSCONSTRAINTSSYSDEPENDSSYSFILESSYSFOREIGNKEYSSYSKEYSSYSPERMSSYSROLESSYSROUTINEPERMSSYSSCHEMASSYSSEQUENCESSYSSTATEMENTSSYSSTATISTICSSYSTABLEPERMSSYSTABLESSYSTRIGGERSSYSVIEWS表。

清单 9-17 和 9-18 遇到了一个架构问题。在创建了EMPLOYEES表之后,假设在删除该表之前抛出了SQLException。下次运行EmployeeDB应用时,当应用试图重新创建EMPLOYEES时会抛出SQLException,因为这个表已经存在。您必须手动删除employee目录,然后才能重新运行EmployeeDB

在创建EMPLOYEES之前调用某种isExist()方法会很好,但是那个方法不存在。然而,我们可以在getTables()的帮助下创建这个方法,清单 9-20 向你展示了如何完成这个任务。

清单 9-20。在创建该表之前确定EMPLOYEES的存在

import java.sql.Connection; import java.sql.DatabaseMetaData; import java.sql.DriverManager; `import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;

class EmployeeDB
{
   public static void main(String[] args)
   {
      String url = "jdbc:derby:employee;create=true";
      try (Connection con = DriverManager.getConnection(url))
      {
         try (Statement stmt = con.createStatement())
         {
            String sql;
            if (!isExist(con, "EMPLOYEES"))
            {
               System.out.println("EMPLOYEES doesn't exist");
               sql = "CREATE TABLE EMPLOYEES(ID INTEGER, NAME VARCHAR(30))";
               stmt.executeUpdate(sql);
            }
            else
               System.out.println("EMPLOYEES already exists");
            sql = "INSERT INTO EMPLOYEES VALUES(1, 'John Doe')";
            stmt.executeUpdate(sql);
            sql = "INSERT INTO EMPLOYEES VALUES(2, 'Sally Smith')";
            stmt.executeUpdate(sql);
            ResultSet rs = stmt.executeQuery("SELECT * FROM EMPLOYEES");
            while (rs.next())
               System.out.println(rs.getInt("ID")+" "+rs.getString("NAME"));
            sql = "DROP TABLE EMPLOYEES";
            stmt.executeUpdate(sql);
         }
      }
      catch (SQLException sqlex)
      {
         while (sqlex != null)
         {
            System.err.println("SQL error : "+sqlex.getMessage());
            System.err.println("SQL state : "+sqlex.getSQLState());
            System.err.println("Error code: "+sqlex.getErrorCode());
            System.err.println("Cause: "+sqlex.getCause());
            sqlex = sqlex.getNextException();
         }
      }
   }
   static boolean isExist(Connection con, String tableName) throws SQLException
   {
      DatabaseMetaData dbmd = con.getMetaData();
      ResultSet rs = dbmd.getTables(null, "APP", tableName, null);
      return rs.next();
   }
}`

清单 9-20 通过引入一个boolean isExist(Connection con, String tableName)类方法来重构清单 9-17 ,该方法在tableName存在时返回 true,并在创建该表之前使用该方法来确定EMPLOYEES的存在。

当指定的表存在时,返回包含一行的ResultSet对象,ResultSetnext()方法返回 true。否则,结果集不包含任何行,并且next()返回 false。

images 注意 isExist()采用默认的APP模式,当涉及用户名时可能不是这样(每个用户的数据库对象都存储在与用户名对应的模式中)。

行星

虽然很有帮助,但是以前的 JDBC 应用在揭示 JDBC 的威力方面有所欠缺,尤其是在与 Swing 结合使用时。为此,我创建了一个名为Planets的更广泛的应用,让您有机会在更有用的环境中探索这些 API。此外,您将发现每个 API 的一些新内容。

Planets应用通过展示八颗行星的图像以及它们的名称和直径(以千米为单位)、质量(以千克为单位)和距太阳的距离(以天文单位或 AUs 为单位,地球距太阳 1 AU)的统计数据,帮助用户了解太阳系的行星。

我设计了两种运行模式。当您执行java Planets initdb时,这个应用创建一个planets数据库,用八个条目填充它的PLANETS表(其中每个条目记录一个基于String的名称、double直径、double质量、double距离和一个存储行星图像的javax.swing.ImageIcon对象),然后终止。当您执行java Planets时,该表的内容被加载,然后您会看到如图图 9-5 所示的 GUI。

images

图 9-5。 Planets便于了解太阳系的行星。

我已经把Planets分成了PlanetsSwingCanvas两个班级:

  • Planets分为namesdiametersmassesdistancesiiPhotosstatic字段,保存从数据库中读取的行星信息;创建 GUI 的JPanel createGUI()类方法;执行java Planets initdb时初始化数据库的void initDB()类方法;一个void loadDB()类方法,当您执行java Planets时,它从数据库的PLANETS表(在 GUI 显示之前)加载行星信息;和启动应用的main()入口点方法。
  • SwingCanvas被组织成iiPhotod(尺寸)static字段,一个SwingCanvas(ImageIcon iiPhoto)构造器,将该组件的尺寸调整为每个图像的尺寸并保存初始图像图标以供显示,一个覆盖的Dimension getPreferredSize()方法,返回该组件的首选尺寸以便完全显示图像,一个覆盖的void paint(Graphics g)方法,将当前图像图标的图像绘制在组件的表面上,以及一个void setPhoto(ImageIcon iiPhoto)方法,将一个新的图像图标分配给画布组件并使其图像绘制在组件的绘图表面上。

简洁的需要限制了我展示完整的源代码,所以我将展示代码片段——您可以在本书附带的代码文件中找到完整的源代码(有关更多信息,请参见本书的介绍)。

考虑下面的initDB()源代码:

static void initDB() {    String[] planets = { "mercury", "venus", "earth", "mars", "jupiter",                         "saturn", "uranus", "neptune" };    double[] diameters = { 4880, 12103.6, 12756.3, 6794, 142984, 120536,                           51118, 49532 };    double[] masses = { 3.3e23, 4.869e24, 5.972e24, 6.4219e23, 1.9e27,                        5.68e26, 8.683e25, 1.0247e26 };    double[] distances = { 0.38, 0.72, 1, 1.52, 5.2, 9.54, 19.218, 30.06 };    String url = "jdbc:derby:planets;create=true";    try (Connection con = DriverManager.getConnection(url))    {       try (Statement stmt = con.createStatement())       {          String sql = "create table planets(name varchar(30),"+                                             "diameter real,"+                                             "mass real,"+                                             "distance real,"+                                             "photo blob)";          stmt.executeUpdate(sql);          sql = "insert into planets values(?, ?, ?, ?, ?)";          try (PreparedStatement pstmt = con.prepareStatement(sql))          {             for (int i = 0; i < planets.length; i++)             {                pstmt.setString(1, planets[i]);                pstmt.setDouble(2, diameters[i]);                pstmt.setDouble(3, masses[i]);                pstmt.setDouble(4, distances[i]);                Blob blob = con.createBlob();                try (ObjectOutputStream oos =                       new ObjectOutputStream(blob.setBinaryStream(1)))                {                   ImageIcon photo = new ImageIcon(planets[i]+".jpg");                   oos.writeObject(photo);                }                catch (IOException ioe)                {                   System.err.println("unable to write "+planets[i]+".jpg");                }                pstmt.setBlob(5, blob);                pstmt.executeUpdate();                blob.free(); // Free the blob and release any held resources.             }          }       }    }    catch (SQLException sqlex)    {       while (sqlex != null)       {          System.err.println("sql error : "+sqlex.getMessage());          System.err.println("sql state : "+sqlex.getSQLState());          System.err.println("error code: "+sqlex.getErrorCode());          System.err.println("cause: "+sqlex.getCause());          sqlex = sqlex.getNextException();       }    } }

这个方法最重要的是演示了如何将一个ImageIcon对象序列化为一个java.sql.Blob对象,然后将Blob对象存储在一个BLOB类型的表列中。

首先调用ConnectionBlob createBlob()方法来创建一个实现Blob接口的对象。因为返回的对象最初不包含数据,所以您需要调用BlobOutputStream setBinaryStream(long pos)方法(pos被传递到 blob 中从 1 开始的开始写入的位置)或它的一个重载setBytes()方法。

如果您选择setBinaryStream(),那么您将使用对象序列化(见第八章)来将对象序列化为 blob。完成后不要忘记关闭对象输出流——try-with-resources 语句很好地为您处理了这项任务。

在创建并填充了Blob对象之后,调用PreparedStatementsetBlob()方法之一(例如void setBlob(int parameterIndex, Blob x))在执行之前将 blob 传递给准备好的语句。在这个执行之后,blob 必须被释放,它的资源也必须被释放。

考虑下面的loadDB()源代码:

static boolean loadDB() {    String url = "jdbc:derby:planets;create=false";    try (Connection con = DriverManager.getConnection(url))    {       try (Statement stmt = con.createStatement())       {          ResultSet rs = stmt.executeQuery("select count(*) from planets");          rs.next();          int size = rs.getInt(1);          names = new String[size];          diameters = new double[size];          masses = new double[size];          distances = new double[size];          iiPhotos = new ImageIcon[size];          rs = stmt.executeQuery("select * from planets");          for (int i = 0; i < size; i++)          {             rs.next();             names[i] = rs.getString(1);             diameters[i] = rs.getDouble(2);             masses[i] = rs.getDouble(3);             distances[i] = rs.getDouble(4);             Blob blob = rs.getBlob(5);             try (ObjectInputStream ois =                    new ObjectInputStream(blob.getBinaryStream()))             {                iiPhotos[i] = (ImageIcon) ois.readObject();             }             catch (ClassNotFoundException|IOException cnfioe)             {                System.err.println("unable to read "+names[i]+".jpg");             }             blob.free(); // Free the blob and release any held resources.          }          return true;       }    }    catch (SQLException sqlex)    {       while (sqlex != null)       {          System.err.println("sql error : "+sqlex.getMessage());          System.err.println("sql state : "+sqlex.getSQLState());          System.err.println("error code: "+sqlex.getErrorCode());          System.err.println("cause: "+sqlex.getCause());          sqlex = sqlex.getNextException();       }       return false;    } }

这个方法是initDB()的逆方法,展示了如何通过执行 SQL 语句(如SELECT COUNT(*) FROM PLANETS)获得结果集的行数,以及如何反序列化 blob 包含的对象。

基于 Swing 的 GUI 由一个SwingCanvas组件(参见第七章)和一个javax.swing.JTable类的实例组成,后者用于显示和编辑单元格的常规二维表格,是显示表格数据的完美组件。

JTable及其众多支持类型(在javax.swingjavax.swing.table包中)的完整讨论超出了本章的范围。相反,请考虑下面摘自createGUI()方法的一段话:

TableModel model = new AbstractTableModel() {    @Override    public int getColumnCount()    {       return 4;    }    @Override    public String getColumnName(int column)    {       switch (column)       {          case 0: return "name";          case 1: return "diameter (km)";          case 2: return "mass (kg)";          default: return "distance (au)";       }    }    @Override    public int getRowCount()    {       return names.length;    }    @Override    public Object getValueAt(int row, int col)    {       switch (col)       {          case 0: return Character.toUpperCase(names[row].charAt(0))+                  names[row].substring(1);          case 1: return diameters[row];          case 2: return masses[row];          default: return distances[row];       }    } }; final JTable table = new JTable(model); table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.setRowSelectionInterval(0, 0); ListSelectionListener lsl; lsl = new ListSelectionListener()       {          @Override          public void valueChanged(ListSelectionEvent lse)          {             sc.setPhoto(iiPhotos[table.getSelectedRow()]);          }       }; table.getSelectionModel().addListSelectionListener(lsl);

每个JTable实例从一个表模型中获取数据,这个表模型是实现javax.swing.table.TableModel接口的类的一个实例。我发现子类化javax.swing.table.AbstractTableModel类很方便,它实现了很多TableModel的方法。

AbstractTableModel没有实现int getColumnCount()(表中的列数)、int getRowCount()(表中的行数)和Object getValueAt(int row, int col)(指定行和列的值),因此覆盖这些方法以返回合适的值的任务落在了表模型实现上。

虽然AbstractTableModel实现了String getColumnName(int column),但是这个实现只返回使用电子表格约定的列的默认名称:A、B、C、...z,AA,AB 等等。若要返回有意义的名称,还必须重写此方法。

表格组件将在必要时调用这些方法。当它这样做时,任何传递的列和/或行值都相对于 0。

创建模型后,它被传递给JTableJTable(TableModel dm)构造函数,后者创建表格组件。除了指定的表模型之外,构造函数还安装了一个默认的列模型(用于选择、添加、删除列以及对列执行其他操作)和一个默认的列表选择模型(用于选择一行或多行)。

JTable(TableModel)的默认列表选择模型允许用户选择一行或多行。因为用户应该一次只能选择一行(应用如何同时显示多个行星图像?),JTablevoid setSelectionMode(int selectionMode)方法被调用,参数javax.swing.ListSelectionModel.SINGLE_SELECTION被传递给selectionMode

当应用开始运行时,第一个表格行(Mercury)应该突出显示(与显示的 Mercury 图像相对应)。这个任务是通过调用JTablevoid setRowSelectionInterval(int index0, int index1)方法完成的。因为只需要选择第 0 行(第一行或最上面的行),所以这个值被传递给index0index1。(setRowSelectionInterval()允许您选择多行,但仅当选择模式不是SINGLE_SELECTION时。)

SwingCanvas组件最初显示水星的图像。当用户选择另一个表行时,必须显示该行的行星图像。这个任务是通过向表组件的ListSelectionModel实现实例注册一个javax.swing.event.ListSelectionListener实现实例来完成的,该实例由JTableListSelectionModel getSelectionModel()方法返回。

ListSelectionListener声明了一个void valueChanged(ListSelectionEvent lse)方法,每当用户选择一行时都会调用这个方法。选中的行是通过调用JTableint getSelectedRow()方法获得的,该方法用于索引到iiPhotos,其ImageIcon实例被传递给SwingCanvasvoid setPhoto(ImageIcon iiPhoto)方法,从而显示新照片。

我为Planets应用选择的架构风格适合完全放在内存中的小型数据库表。但是,您可能会遇到这样的情况:需要从具有数百万(或更多)行的数据库表中获取数据,并用所有这些数据填充一个表组件。因为没有足够的内存来实现这个,你会怎么做?

解决方案是只将少量的行读入缓存(也许在参考 API 的帮助下——参见第四章)并跟踪当前位置。例如,假设每一行都有一个唯一的从 1 开始的整数标识符,您可以指定一个 SQL 语句,如SELECT * FROM EMPLOYEE WHERE ID >= 20 && ID <= 30来返回那些其ID列包含从 20 到 30 的整数值之一的行。另外,查看“Java:将大数据加载到 JTable 或 JList”([www.snippetit.com/2009/11/java-loading-large-data-into-jtable-or-jlist/](http://www.snippetit.com/2009/11/java-loading-large-data-into-jtable-or-jlist/))来学习如何创建一个合适的表模型用于这种情况。

images 注意要了解更多关于 JDBC 的信息,请查看 Java 教程的“JDBC:数据库访问”部分。

演习

以下练习旨在测试您对网络 API 和 JDBC 的理解:

1.创建一个网络版的 21 点——这个游戏要实现的版本将在本练习后描述。实现一个用作庄家的BJDealer应用和一个用作玩家的BJPlayer应用。BJDealer等待玩家连接指示,然后创建一个后台线程为玩家服务——这使得庄家可以和多个玩家玩独立游戏。当BJDealer接受来自玩家的套接字时,它创建java.io.ObjectInputStreamjava.io.ObjectOutputStream对象用于与玩家通信。类似地,当BJPlayer创建一个用于与经销商通信的套接字时,它会创建ObjectInputStreamObjectOutputStream对象。因为ObjectInputStream(InputStream in)构造函数会阻塞,直到相应的ObjectOutputStream实例已经写入并刷新了序列化流头,所以让BJDealerBJPlayer中的每一个立即调用所创建的ObjectOutputStream实例上的flush()方法。BJDealerCard对象和基于String的状态消息序列化到BJPlayerBJPlayerCard对象和基于String的命令序列化到BJDealerBJDealer不显示用户界面,而BJPlayer显示图 9-6 所示的用户界面。

images

图 9-6。 BJPlayer的 GUI 由一个渲染扑克牌的组件(玩家的牌在上半部分,庄家的牌在下半部分)和一个显示状态消息和按钮的面板组成。

玩家点击发牌按钮,让庄家发一手新牌。该按钮随后被禁用,直到玩家输或赢。玩家单击“击中”按钮向庄家请求另一张牌,并在庄家站着时单击“站立”按钮—这些按钮在发牌启用时被禁用。最后,玩家点击退出按钮终止游戏。

为了节省您的工作,清单 9-21 展示了BJDealerBJPlayer各自使用的Card类。

清单 9-21。根据花色和等级描述扑克牌

`import java.io.Serializable;

import java.util.ArrayList;
import java.util.List;

class Card implements Serializable
{
   enum Suit { CLUBS, DIAMONDS, HEARTS, SPADES }
   enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT, NINE, TEN,
               JACK, QUEEN, KING;
               int getValue()
               {
                  return ordinal()+1;
               }
             }
   private Suit suit;
   private Rank rank;
   private static final List initialDeck = new ArrayList();
   Card(Suit suit, Rank rank)
   {
      this.suit = suit;
      this.rank = rank;
   }
   Rank getRank()
   {
      return rank;
   }
   Suit getSuit()
   {
      return suit;
   }
   int getValue()
   {
      return rank.ordinal()+1;
   }
   static
   {
      for (Suit suit: Suit.values())
         for (Rank rank: Rank.values())
            initialDeck.add(new Card(suit, rank));
   }
   static List newDeck() // Return a new unshuffled deck.    {
      // Copy initial deck to new deck.
      List deck = new ArrayList(initialDeck);
      return deck;
   }
}`

images 二十一点是一种纸牌游戏,玩家与发牌者竞争,看谁能最接近 21 而不被超过。第一个达到 21 分的玩家获胜并结束本轮游戏。庄家开始一轮游戏时,先给玩家发两张牌,自己也发两张牌。玩家看到了她的两张牌,并且只看到了庄家手中的第一张牌。庄家检查她的手牌,看是否有21 点(正好 21 点)。在这种情况下,玩家输了,除非玩家也有 21 点。在这种情况下,结果被称为,没有人赢或输。当庄家的手牌不是 21 点时,玩家检查她的牌是否有 21 点,如果是这样,玩家获胜。否则,由于庄家和玩家最初都没有 21 点,游戏进行如下:玩家可以请求击中(从庄家处获得额外的牌——每次击中一张),直到玩家的牌总数超过 21(玩家输了)或者玩家决定站在(玩家对她的牌满意,并将等待看庄家的牌如何进展)。玩家通常在他们认为自己的牌不错和/或另一击可能导致他们超过 21 时才会站起来。在玩家站起来之后,发牌者继续向玩家展示她的第二张牌。当庄家的牌数少于 17 时,他总是受到打击,当他的牌数等于或大于 17 时,他总是站着不动。当评估庄家的临时得分以确定是否需要击中时,ACE 总是计为 11,但在最终决定中可能计为 1。当庄家完成她的击球时,她的手与玩家的手进行比较。当它更高时,庄家赢;当它更低时,玩家获胜(除非玩家超过 21);当它们相同时,这是一种推动,没有人会赢。从 2 到 10 的牌都有其面值,杰克是 10,皇后是 10,国王是 10,王牌是 1 或 11(直到手牌被评估)。

2.用新的统计数据扩展Planets应用(例如,卫星数量、成分和内部温度)。此外,提供关于这个星球的附加注释(通过标签显示)。将所有这些额外信息保存在数据库中,并在应用开始运行时检索它们。你会在[nineplanets.org/](http://nineplanets.org/)找到有用的信息。

总结

网络是可以在用户之间共享硬件和软件的互连节点的集合。主机节点之间的通信通过套接字进行,其中套接字是两个进程之间的通信链路中的端点。端点由标识主机的 IP 地址和标识该网络节点上运行的进程的端口号组成。

一个进程将消息写入套接字,套接字将该消息分解成一系列数据包,并将这些数据包转发给另一个进程的套接字,套接字将这些数据包重新组合成原始消息供该进程使用。

TCP 用于通过来回发送消息来创建两台主机之间的持续对话。在此对话发生之前,必须在这些主机之间建立连接。建立连接后,TCP 进入一种模式,发送一个信息包并等待信息包正确到达的应答(或者当由于网络问题应答没有到达时等待超时)。这种发送/回复循环保证了可靠的连接。

因为建立连接需要时间,而且因为需要接收应答确认(或超时)而发送数据包也需要时间,所以 TCP 相当慢。相比之下,UDP 不需要连接和数据包确认,比 TCP 快得多。然而,UDP 不像 TCP 那样可靠(不能保证数据包会正确到达,甚至到达),因为没有确认。此外,UDP 仅限于单包单向会话。

java.net包提供了用于执行基于 TCP 的通信的SocketServerSocket类。它还提供了用于执行 UDP 通信的DatagramSocketDatagramPacketMulticastSocket类。

URL 是指定资源(例如网页)在基于 TCP/IP 的网络(例如因特网)上的位置的字符串。此外,它还提供了检索该资源的方法。

java.net包提供了用于访问基于 URL 的资源的URLURLConnection类。它还提供了用于编码和解码 URL 的URLEncoderURLDecoder类,以及用于执行基于 URI 的操作(例如,相对化)并返回包含结果的URL实例的URI类。

HTTP 支持认证,由此客户端(例如,浏览器用户)必须证明他们的真实性。已经提出了各种认证方案来处理这个任务;例如,基本和摘要。Java 提供了Authenticator和相关类型,以便联网的 Java 应用可以与这些认证方案交互。

服务器应用通常使用HTTP cookie(状态对象)——cookie来保存客户端上的少量信息。Java 通过CookieManagerCookieHandler和相关类型支持 cookie 管理。

数据库是有组织的数据集合。虽然数据库有很多种(例如,层次型、面向对象型和关系型),但关系型数据库很常见,它们将数据组织成表,每行存储一个项目,如雇员,每列存储一个项目属性,如雇员姓名,这些表可以相互关联。

除了最普通的数据库(例如平面文件数据库),数据库都是通过 DBMS 创建和管理的。RDBMSes 支持 SQL 来处理表等等。

Java 通过其面向关系数据库的 JDBC (Java 数据库连接)API 支持数据库创建、访问等。JDK 还提供了 Java DB,这是一个 RDBMS,可以用来测试支持 JDBC 的应用。

JDBC 提供了许多功能,包括连接到数据源的驱动程序、数据源连接、存储有关数据源问题的各种信息的异常、执行 SQL 的语句(常规、准备好的和可调用的)、存储 SQL 查询结果的结果集以及了解有关数据源的更多信息的元数据。预处理语句是预编译语句,可调用语句用于执行存储过程。

第十章向您介绍 XML,以及 Java 的 SAX、DOM、StAX、XPath 和 XSLT APIs。您甚至简要了解了它的验证 API。

十、解析、创建和转换 XML 文档

应用通常使用 XML 文档来存储和交换数据。Java 通过 SAX、DOM、StAX、XPath 和 XSLT APIs 为 XML 提供了广泛的支持。理解这些 API 是探索依赖于 XML 的其他 Java APIs 的先决条件;例如,web 服务(在第十一章中讨论)。

第十章向您介绍 SAX、DOM、StAX、XPath 和 XSLT。在深入研究这些 API 之前,本章为那些不熟悉这项技术的人提供了 XML 的介绍。

images 注意 SAX、DOM、StAX、XPath 和 XSLT 是一个更广泛的 API 的独立 API 成员,该 API 被称为用于 XML 处理的 Java API(JAXP)。创建 JAXP 是为了让应用使用 XML 处理器独立于处理器实现来解析、创建、转换或执行 XML 文档上的其他操作,通过提供一个可插拔层,让供应商提供他们自己的实现,而无需在应用代码中引入依赖性。Java 7 支持 JAXP 1.4.5。

什么是 XML?

XML (可扩展标记语言)是一种元语言(一种用于描述其他语言的语言),用于定义词汇(自定义标记语言),这是 XML 的重要性和普及性的关键。基于 XML 的词汇表(例如 XHTML)让您能够以有意义的方式描述文档。

XML 词汇表文档类似于 HTML(参见[en.wikipedia.org/wiki/HTML](http://en.wikipedia.org/wiki/HTML))文档,因为它们是基于文本的,由标记(文档逻辑结构的编码描述)和内容(不被解释为标记的文档文本)组成。标记通过标签(尖括号分隔的语法结构)来证明,每个标签都有一个名称。此外,一些标签有属性(名称-值对)。

images XML 和 HTML 是标准通用标记语言(SGML) 的后代,是创建词汇的原始元语言——XML 本质上是 SGML 的限制形式,而 HTML 是 SGML 的应用。XML 和 HTML 之间的关键区别在于,XML 邀请您使用自己的标签和规则创建自己的词汇表,而 HTML 为您提供一个预先创建的词汇表,它有自己固定的标签和规则集。XHTML 和其他基于 XML 的词汇表是 XML 应用。创建 XHTML 是为了更清晰地实现 HTML。

如果您以前没有接触过 XML,您可能会对它的简单性和它的词汇与 HTML 的相似程度感到惊讶。学习如何创建 XML 文档并不需要成为火箭科学家。为了证明这一点,请查看清单 10-1 。

清单 10-1。基于 XML 的烤奶酪三明治食谱

<recipe>
   <title>
      Grilled Cheese Sandwich
   </title>
   <ingredients>
      <ingredient qty="2">
         bread slice
      </ingredient>
      <ingredient>
         cheese slice
      </ingredient>
      <ingredient qty="2">
         margarine pat
      </ingredient>
   </ingredients>
   <instructions>
      Place frying pan on element and select medium heat. For each bread slice, smear
      one pat of margarine on one side of bread slice. Place cheese slice between bread
      slices with margarine-smeared sides away from the cheese. Place sandwich in frying
      pan with one margarine-smeared side in contact with pan. Fry for a couple of
      minutes and flip. Fry other side for a minute and serve.
   </instructions>
</recipe>

清单 10-1 展示了一个 XML 文档,描述了制作烤奶酪三明治的食谱。这个文档类似于 HTML 文档,因为它由标签、属性和内容组成。然而,相似之处也就到此为止了。这种非正式的菜谱语言呈现了自己的<recipe><ingredients>和其他标签,而不是 HTML 标签,比如<html><head><img><p>

images 注意虽然清单 10-1 的<title></title>标签也可以在 HTML 中找到,但是它们与它们的 HTML 对应物不同。Web 浏览器通常会在标题栏中显示这些标签之间的内容。相比之下,清单 10-1 的<title></title>标签之间的内容可能会显示为标题、大声朗读或以其他方式呈现,这取决于解析该文档的应用。

XML 文档基于 XML 声明、元素和属性、字符引用和 CDATA 节、名称空间以及注释和处理指令。学习完这些基础知识后,您将了解 XML 文档的良好格式意味着什么,以及 XML 文档的有效性意味着什么。

XML 声明

XML 文档通常以 XML 声明开始,这是一种特殊的标记,通知 XML 解析器该文档是 XML。在清单 10-1 中缺少 XML 声明表明这种特殊的标记不是强制性的。当 XML 声明存在时,它前面不能出现任何内容。

XML 声明至少看起来像<?xml version="1.0"?>,其中非可选的version属性标识文档符合的 XML 规范的版本。该规范的初始版本(1.0)于 1998 年推出,并得到了广泛的实施。

images 注意维护 XML 的万维网联盟(W3C)在 2004 年发布了 1.1 版本。该版本主要支持使用 EBCDIC 平台上使用的行尾字符(见[en.wikipedia.org/wiki/EBCDIC](http://en.wikipedia.org/wiki/EBCDIC)),以及使用 Unicode 3.2 中没有的脚本和字符(见[en.wikipedia.org/wiki/Unicode](http://en.wikipedia.org/wiki/Unicode))。与 XML 1.0 不同,XML 1.1 没有被广泛实现,应该只由那些需要其独特特性的人使用。

XML 支持 Unicode,这意味着 XML 文档完全由 Unicode 字符集中的字符组成。文档的字符被编码成字节以便存储或传输,编码是通过 XML 声明的可选属性encoding指定的。一种常见的编码是 UTF-8 (见[en.wikipedia.org/wiki/UTF-8](http://en.wikipedia.org/wiki/UTF-8)),这是 Unicode 字符集的可变长度编码。UTF-8 是 ASCII 的严格超集(见[en.wikipedia.org/wiki/Ascii](http://en.wikipedia.org/wiki/Ascii)),这意味着纯 ASCII 文本文件也是 UTF-8 文档。

images 注意在没有 XML 声明的情况下,或者当 XML 声明的encoding属性不存在时,XML 解析器通常会在文档的开头寻找一个特殊的字符序列来确定文档的编码。这个字符序列被称为字节顺序标记(BOM) ,由编辑程序(如微软 Windows 记事本)根据 UTF-8 或其他编码保存文档时创建。例如,十六进制序列 EF BB BF 表示编码为 UTF-8。同样,FE FF 表示 UTF-16 大端(见[en.wikipedia.org/wiki/UTF-16/UCS-2](http://en.wikipedia.org/wiki/UTF-16/UCS-2)),FF FE 表示 UTF-16 小端,00 00 FE FF 表示 UTF-32 大端(见[en.wikipedia.org/wiki/UTF-16/UCS-2](http://en.wikipedia.org/wiki/UTF-16/UCS-2)),FF FE 00 00 表示 UTF-32 小端。如果没有物料清单,则假定为 UTF-8。

如果除了 ASCII 字符集之外你从来不使用字符,你可能会忘记encoding属性。但是,如果您的母语不是英语,或者如果您被要求创建包含非 ASCII 字符的 XML 文档,您需要正确地指定encoding。例如,如果您的文档包含来自非英语西欧语言的 ASCII plus 字符(例如,法语、葡萄牙语和其他语言中使用的 cedilla),您可能希望选择ISO-8859-1作为encoding属性的值——以这种方式编码的文档可能比使用 UTF-8 编码的文档更小。清单 10-2 向您展示了生成的 XML 声明。

清单 10-2。包含非汉字字符的编码文档

<?xml version="1.0" **encoding="ISO-8859-1"**?>
<movie>
   <name>Le Fabuleux Destin d'Amélie Poulain</name>
   <language>français</language>
</movie>

可以出现在 XML 声明中的最后一个属性是standalone。这个可选属性决定了 XML 文档是否依赖于外部 DTD(将在本章后面讨论)——它的值是no——或者不是——它的值是yes。值默认为no,暗示有外部 DTD。然而,因为没有 DTD 的保证,standalone很少使用,也不会进一步讨论。

元素和属性

XML 声明之后是元素的层次(树)结构,其中元素是由开始标签(如<name>)和结束标签(如</name>)分隔的文档的一部分,或者是一个空元素标签(名称以正斜杠/结尾的独立标签,如<break/>)。开始标签和结束标签包围内容和可能的其他标记,而空元素标签不包围任何东西。图 10-1 展示了清单 10-1 的 XML 文档树结构。

images

图 10-1。 清单 10-1 的树形结构根植于recipe元素。

与 HTML 文档结构一样,XML 文档的结构锚定在一个根元素(最顶层的元素)中。在 HTML 中,根元素是html(<html></html>标签对)。与 HTML 不同,您可以为 XML 文档选择根元素。图 10-1 显示根元素为recipe

不像其他元素有父元素recipe没有父元素。同样,recipeingredients子元素 : recipe的子元素是titleingredientsinstructions;而 ingredients的孩子就是ingredient的三个实例。titleinstructionsingredient元素没有子元素。

元素可以包含子元素、内容或者混合内容(子元素和内容的组合)。清单 10-2 揭示了movie元素包含namelanguage子元素,还揭示了这些子元素中的每一个都包含内容(例如,language包含français)。清单 10-3 给出了另一个例子,展示了混合内容以及子元素和内容。

清单 10-3。包含混合内容的abstract元素

<?xml version="1.0"?>
<article title="the rebirth of javafx" lang="en">
   <abstract>
      JavaFX 2.0 marks a significant milestone in the history of JavaFX. Now that
      Sun Microsystems has passed the torch to Oracle, we have seen the demise of
      JavaFX Script and the emerge of Java APIs (such as
      <code-inline>javafx.application.Application</code-inline>) for interacting
      with this technology. This article introduces you to this new flavor of
      JavaFX, where you learn about JavaFX 2.0 architecture and key APIs.
   </abstract>
   <body>
   </body>
</article>

这个文档的根元素是article,它包含了abstractbody子元素。abstract元素将内容与包含内容的code-inline元素混合在一起。相反,body元素是空的。

images 注意与清单 10-1 和清单 10-2 ,清单 10-3 也包含空格(不可见的字符,如空格、制表符、回车符和换行符)。XML 规范允许在文档中添加空白。内容中出现的空白(如单词之间的空格)被视为内容的一部分。相反,解析器通常会忽略结束标记和下一个开始标记之间出现的空白。这样的空白不被认为是内容的一部分。

XML 元素的开始标记可以包含一个或多个属性。例如,清单 10-1 的<ingredient>标签有一个qty(数量)属性,清单 10-3 的<article>标签有titlelang属性。属性提供了关于元素的附加信息。例如,qty表示可以添加的成分的量,title表示文章的标题,lang表示文章使用的语言(en表示英语)。属性可以是可选的。例如,如果未指定qty,则采用默认值1

images 注意元素和属性名称可以包含来自英语或其他语言的任何字母数字字符,也可以包含下划线(_)、连字符(-)、句点(.)和冒号(:)标点字符。冒号应该只用于名称空间(将在本章后面讨论),并且名称不能包含空格。

字符引用和 CDATA 节

某些字符不能按字面意思出现在开始标记和结束标记之间的内容中,也不能出现在属性值中。例如,不能在开始标记和结束标记之间放置文字字符<,因为这样做会使 XML 解析器误以为遇到了另一个标记。

这个问题的一个解决方案是用一个字符引用替换字面字符,这是一个代表字符的代码。字符引用分为数字字符引用或字符实体引用:

  • 数字字符引用通过字符的 Unicode 码位引用字符,遵循格式&#nnnn;(不限四位)或&#xhhhh;(不限四位),其中 nnnn 提供码位的十进制表示, hhhh 提供十六进制表示。例如,&#0931;&#x03A3;代表希腊文大写字母 sigma。虽然 XML 要求&#x*hhhh*;中的x是小写的,但是它很灵活,前导零在两种格式中都是可选的,并且允许您为每个 h 指定大写或小写字母。因此,&#931;&#x3A3;&#x03a3;也是希腊大写字母 sigma 的有效表示。
  • 一个字符实体引用通过一个实体(别名数据)的名称引用一个字符,该实体指定所需的字符作为其替换文本。字符实体引用是由 XML 预定义的,格式为&*name*;,其中 name 是实体的名称。XML 预定义了五个字符实体引用:&lt;(<)&gt;(>)&amp;(&)&apos;(')和&quot; ( ")。

*考虑一下<expression>6 < 4</expression>。你可以用数字参考&#60;代替<,产生<expression>6 &#60; 4</expression>,或者更好的是用&lt;,产生<expression>6 &lt; 4</expression>。第二种选择更清晰,更容易记忆。

假设您想在一个元素中嵌入一个 HTML 或 XML 文档。为了使嵌入的文档能够被 XML 解析器接受,您需要将每个文字字符<(标签的开始)和&(实体的开始)替换为它的&lt;&amp;预定义的字符实体引用,这是一项繁琐且可能容易出错的工作——您可能会忘记替换其中的一个字符。为了避免繁琐和潜在的错误,XML 以 CDATA(字符数据)部分的形式提供了一种替代方法。

一个 CDATA 部分是由前缀<![CDATA[和后缀]]>包围的一段文字 HTML 或 XML 标记和内容。您不需要在 CDATA 部分中指定预定义的字符实体引用,如清单 10-4 所示。

清单 10-4。将一个 XML 文档嵌入另一个文档的 CDATA 部分

<?xml version="1.0"?> <svg-examples>    <example>       The following Scalable Vector Graphics document describes a blue-filled and       black-stroked rectangle.       <**![CDATA[**<svg width="100%" height="100%" version="1.1"            >          <rect width="300" height="100"                style="fill:rgb(0,0,255);stroke-width:1; stroke:rgb(0,0,0)"/>       </svg>**]]>**    </example> </svg-examples>

清单 10-4 在 SVG 示例文档的example元素中嵌入了一个可缩放矢量图形(SVG)【参见[en.wikipedia.org/wiki/Svg](http://en.wikipedia.org/wiki/Svg)】XML 文档。SVG 文档被放在 CDATA 部分,这样就不需要用&lt;预定义的字符实体引用替换所有的<字符。

名称空间

创建结合不同 XML 语言特性的 XML 文档是很常见的。当元素和其他 XML 语言特性出现时,命名空间用于防止名称冲突。如果没有名称空间,XML 解析器就无法区分同名元素或其他具有不同含义的语言特性,例如,来自两种不同语言的两个同名title元素。

images 注意名称空间不是 XML 1.0 的一部分。它们是在这个规范发布一年后出现的。为了确保向后兼容 XML 1.0,名称空间利用了冒号字符,这是 XML 名称中的合法字符。不识别名称空间的解析器返回包含冒号的名称。

名称空间是一个基于统一资源标识符(URI)的容器,通过为其包含的标识符提供唯一的上下文来帮助区分 XML 词汇表。命名空间 URI 与一个命名空间前缀(URI 的别名)相关联,通常是通过在 XML 文档的根元素上指定xmlns属性本身(表示默认命名空间)或xmlns:*prefix*属性(表示被标识为 prefix 的命名空间),并将 URI 分配给该属性。

images 注意一个名称空间的作用域从声明它的元素开始,应用于该元素的所有内容,除非被另一个具有相同前缀名称的名称空间声明覆盖。

当指定了 prefix 时,它和一个冒号字符被添加到属于该名称空间的每个元素标签的名称前面——参见清单 10-5 。

清单 10-5。引入一对名称空间

<?xml version="1.0"?>
<h:html xmlns:h="http://www.w3.org/1999/xhtml"
        xmlns:r="http://www.tutortutor.ca/">
   <h:head>
      <h:title>
         Recipe
      </h:title>
   </h:head>
   <h:body>
   <r:recipe>
      <r:title>
         Grilled Cheese Sandwich
      </r:title>
      <r:ingredients>
         <h:ul>
         <h:li>
         <r:ingredient qty="2">
            bread slice
         </r:ingredient>
         </h:li>
         <h:li>
         <r:ingredient>
            cheese slice
         </r:ingredient>
         </h:li>
         <h:li>
         <r:ingredient qty="2">
            margarine pat
         </r:ingredient>
         </h:li>
         </h:ul>
      </r:ingredients>
      <h:p>
      <r:instructions>
         Place frying pan on element and select medium heat. For each bread slice, smear
         one pat of margarine on one side of bread slice. Place cheese slice between
         bread slices with margarine-smeared sides away from the cheese. Place sandwich
         in frying pan with one margarine-smeared side in contact with pan. Fry for a
         couple of minutes and flip. Fry other side for a minute and serve.
      </r:instructions>
      </h:p>
   </r:recipe>
   </h:body>
</h:html>

清单 10-5 描述了一个文档,它将 XHTML 语言(见[en.wikipedia.org/wiki/XHTML](http://en.wikipedia.org/wiki/XHTML))中的元素与菜谱语言中的元素结合起来。所有与 XHTML 相关的元素标签都以h:为前缀,所有与菜谱语言相关的元素标签都以r:为前缀。

h:前缀与[www.w3.org/1999/xhtml](http://www.w3.org/1999/xhtml) URI 相关联,r:前缀与[www.tutortutor.ca/](http://www.tutortutor.ca/) URI 相关联。XML 不要求 URIs 指向文档文件。它只要求它们是唯一的,以保证名称空间的唯一性。

本文档将菜谱数据从 XHTML 元素中分离出来,这使得保存该数据的结构成为可能,同时也允许符合 XHTML 的网络浏览器(例如,Google Chrome)通过网页呈现菜谱(见图 10-2 )。

images

图 10-2。谷歌 Chrome 通过 XHTML 标签呈现菜谱数据。

当标签的属性属于元素时,这些属性不需要加上前缀。比如<r:ingredient qty="2">中没有前缀qty。但是,属于其他名称空间的属性需要前缀。例如,假设您想要向文档的<r:title>标签添加一个 XHTML style属性,以便在通过应用显示时为菜谱标题提供样式。您可以通过在 title 标签中插入一个 XHTML 属性来完成这项任务,如下所示:<r:title h:style="font-family: sans-serif;">。XHTML style属性带有前缀h:,因为该属性属于 XHTML 语言名称空间,而不属于 recipe 语言名称空间。

当涉及多个名称空间时,将其中一个名称空间指定为默认名称空间会很方便,这样可以减少输入名称空间前缀的繁琐。考虑清单 10-6 中的。

清单 10-6。指定默认名称空间

`

                    Recipe                                    Grilled Cheese Sandwich` `                       
             
  •                       bread slice                    
  •          
  •                       cheese slice                    
  •          
  •                       margarine pat                    
  •          
      
      

                Place frying pan on element and select medium heat. For each bread slice, smear          one pat of margarine on one side of bread slice. Place cheese slice between          bread slices with margarine-smeared sides away from the cheese. Place sandwich          in frying pan with one margarine-smeared side in contact with pan. Fry for a          couple of minutes and flip. Fry other side for a minute and serve.              

   
    `

清单 10-6 指定了 XHTML 语言的默认名称空间。没有 XHTML 元素标签需要以h:为前缀。然而,配方语言元素标签仍然必须以前缀r:为前缀。

注释和处理说明

XML 文档可以包含注释,注释是以<!--开始,以-->结束的字符序列。例如,您可以将<!-- Todo -->放在清单 10-3 的body元素中,以提醒自己需要完成该元素的编码。

注释用于阐明文档的各个部分。它们可以出现在 XML 声明之后的任何地方,除了在标记内,不能嵌套,不能包含双连字符(--),因为这样做可能会使 XML 解析器混淆,认为注释已经结束,出于同样的原因,不应该包含连字符(-),并且通常在处理过程中被忽略。评论不内容。

XML 还允许存在处理指令。处理指令是对解析文档的应用可用的指令。指令以<?开始,以?>结束。在<?前缀之后是一个名为目标的名字。该名称通常标识处理指令所针对的应用。处理指令的其余部分包含适合应用格式的文本。处理指令的两个例子是<?xml-stylesheet href="modern.xsl" type="text/xml"?>(将可扩展样式表语言【XSL】样式表【参见[en.wikipedia.org/wiki/XSL](http://en.wikipedia.org/wiki/XSL)与 XML 文档相关联)和<?php /* PHP code */ ?>(将 PHP 代码片段传递给应用)。虽然 XML 声明看起来像一个处理指令,但事实并非如此。

images 注意XML 声明不是处理指令。

格式良好的文档

HTML 是一种松散的语言,在这种语言中,可以无序地指定元素,可以省略结束标记,等等。web 浏览器页面布局代码的复杂性部分是由于需要处理这些特殊情况。相比之下,XML 是一种更严格的语言。为了使 XML 文档更容易解析,XML 要求 XML 文档遵循某些规则:

  • 所有元素必须有开始和结束标签,或者由空元素标签组成。例如,与通常没有对应的</p>标签的 HTML <p>标签不同,</p>也必须从 XML 文档的角度存在。
  • 标签必须正确嵌套。例如,虽然您可能在 HTML 中指定了<b><i>JavaFX</b></i>,但是 XML 解析器会报告一个错误。相比之下,<b><i>JavaFX</i></b>不会导致错误。
  • 所有属性值都必须加上引号。单引号(')或双引号(")都是允许的(尽管双引号是更常见的指定引号)。省略这些引号是错误的。
  • 空元素必须被正确格式化。例如,HTML 的<br>标签在 XML 中必须被指定为<br/>。您可以在标签名称和/字符之间指定一个空格,尽管空格是可选的。
  • 小心箱子。XML 是一种区分大小写的语言,其中大小写不同的标签(如<author><Author>)被认为是不同的。将不同大小写的开始和结束标签混在一起是错误的,例如,<author></Author>

意识到名称空间的 XML 解析器执行两个额外的规则:

  • 所有元素和属性名称不得包含一个以上的冒号字符。
  • 实体名称、处理指令目标或符号名称(稍后讨论)都不能包含冒号。

符合这些规则的 XML 文档是格式良好的。该文档具有逻辑清晰的外观,并且更容易处理。XML 解析器只会解析格式良好的 XML 文档。

有效证件

对于 XML 文档来说,格式良好并不总是足够的;在许多情况下,文件也必须是有效的。一个有效的文档遵守约束。例如,可以对清单 10-1 的菜谱文档 设置一个约束,以确保ingredients元素总是在instructions元素之前;也许一个申请必须首先处理ingredients

images XML 文档验证类似于编译器分析源代码,以确保代码在机器上下文中有意义。例如,intcount=1;都是有效的 Java 字符序列,但是1 count ; int =不是有效的 Java 结构(而int count = 1;是有效的 Java 结构)。

一些 XML 解析器执行验证,而其他解析器不执行验证,因为验证解析器更难编写。执行验证的解析器将 XML 文档与语法文档进行比较。与该文档的任何偏差都将作为错误报告给应用—该文档无效。应用可以选择修复错误或拒绝文档。与良构性错误不同,有效性错误不一定是致命的,解析器可以继续解析文档。

images 注意默认情况下,验证 XML 解析器通常不进行验证,因为验证非常耗时。必须指导他们执行验证。

语法文件是用一种特殊的语言写的。JAXP 支持的两种常用语法语言是文档类型定义和 XML 模式。

单据类型定义

文档类型定义(DTD) 是规定 XML 文档语法的最古老的语法语言。DTD 语法文档(称为 DTD)是根据一种严格的语法编写的,该语法规定了什么元素可以出现在文档的什么部分,元素中包含什么(子元素、内容或混合内容)以及可以指定什么属性。例如,DTD 可能会指定一个recipe元素必须有一个ingredients元素,后跟一个instructions元素。

清单 10-7 给出了一个配方语言的 DTD,该配方语言用于构建清单 10-1 的文档。

清单 10-7。配方语言的 DTD

<!ELEMENT recipe (title, ingredients, instructions)>
<!ELEMENT title (#PCDATA)>
<!ELEMENT ingredients (ingredient+)>
<!ELEMENT ingredient (#PCDATA)>
<!ELEMENT instructions (#PCDATA)>
<!ATTLIST ingredient qty CDATA "1">

该 DTD 首先声明配方语言的元素。元素声明采用<!ELEMENT *name* *content-specifier*>的形式,其中 name 是任何合法的 XML 名称(例如,它不能包含空格),而 content-specifier 标识元素中可以出现的内容。

第一个元素声明声明 XML 文档中只能出现一个recipe元素——这个声明并不意味着recipe是根元素。此外,这个元素必须包含titleingredientsinstructions子元素中的一个,并且按照这个顺序。子元素必须指定为逗号分隔的列表。此外,列表总是用括号括起来。

第二个元素声明声明title元素包含解析的字符数据(非标记文本)。第三个元素声明声明至少一个ingredient元素必须出现在ingredients中。+字符是表示一个或多个的正则表达式的一个例子。其他可能使用的表达式有*(零或更多)和?(一次或根本不使用)。第四个和第五个元素声明与第二个类似,声明ingredientinstructions元素包含解析的字符数据。

images 注意元素声明支持其他三种内容说明符。您可以指定<!ELEMENT *name* ANY>来允许任何类型的元素内容,或者指定<!ELEMENT *name* EMPTY>来禁止任何元素内容。要声明一个元素包含混合内容,您可以指定#PCDATA和一个元素名称列表,用竖线(|)分隔。例如,<!ELEMENT ingredient (#PCDATA | measure | note)*>声明ingredient元素可以包含已解析的字符数据、零个或多个measure元素以及零个或多个note元素。它没有指定解析的字符数据和这些元素出现的顺序。但是,#PCDATA必须是列表中指定的第一项。在此上下文中使用正则表达式时,它必须出现在右括号的右侧。

清单 10-7 的 DTD 最后声明了菜谱语言的属性,其中只有一个:qty。属性声明采用<!ATTLIST *ename aname type default-value*>的形式,其中 ename 是属性所属元素的名称, aname 是属性的名称, type 是属性的类型, default-value 是属性的默认值。

属性声明将qty标识为ingredient的属性。它还说明了qty的类型是CDATA(任何不包括&符号、小于或大于符号或双引号的字符串都可能出现;这些字符可以分别通过&amp;&lt;&gt;&quot;来表示,并且qty是可选的,当不存在时采用默认值1

关于属性的更多信息

DTD 允许您指定附加的属性类型:ID(为标识元素的属性创建唯一的标识符)、IDREF(属性值是位于文档中其他位置的元素)、IDREFS(值由多个IDREF组成)、ENTITY(可以使用外部二进制数据或未解析的实体)、ENTITIES(值由多个实体组成)、NMTOKEN(值限于任何有效的 XML 名称)、NMTOKENS(值由多个 XML 名称组成)、NOTATION(值已经通过指定)值用竖线分隔)。

您可以不逐字指定默认值,而是指定#REQUIRED来表示属性必须始终具有某个值(<!ATTLIST *ename aname type* #REQUIRED >),#IMPLIED来表示属性是可选的,并且不提供默认值(<!ATTLIST *ename aname type* #IMPLIED>,或者#FIXED来表示属性是可选的,并且在使用时必须始终采用 DTD 分配的默认值(<!ATTLIST *ename aname type* #FIXED "value">

您可以在一个ATTLIST声明中指定属性列表。例如,<!ATTLIST *ename aname1 type1 default-value1 aname2 type2 default-value2*>声明了两个标识为 aname1aname2 的属性。

基于 DTD 的验证 XML 解析器在验证文档之前,要求文档包含一个文档类型声明,用于标识指定文档语法的 DTD。

images 注意单据类型定义和单据类型声明是两回事。DTD 首字母缩略词标识文档类型定义,从不标识文档类型声明。

文档类型声明紧跟在 XML 声明之后,并以下列方式之一指定:

  • <!DOCTYPE *root-element-name* SYSTEM *uri*>通过 uri 引用外部但私有的 DTD。引用的 DTD 不可用于公众审查。例如,我可能将我的食谱语言的 DTD 文件(recipe.dtd)存储在我的[www.tutortutor.ca](http://www.tutortutor.ca)网站上的私有dtds目录中,并使用<!DOCTYPE recipe SYSTEM "http://www.tutortutor.ca/dtds/recipe.dtd">通过系统标识符[www.tutortutor.ca/dtds/recipe.dtd](http://www.tutortutor.ca/dtds/recipe.dtd)来标识该 DTD 的位置。
  • <!DOCTYPE *root-element-name* PUBLIC *fpi uri*>通过 fpi 、一个正式公共标识符(参见[en.wikipedia.org/wiki/Formal_Public_Identifier](http://en.wikipedia.org/wiki/Formal_Public_Identifier))uri引用一个外部的但是公共的 DTD。如果验证 XML 解析器不能通过公共标识符 fpi 定位 DTD,它可以使用系统标识符 uri 定位 DTD。例如,<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">首先通过公共标识符-//W3C//DTD XHTML 1.0 Transitional//EN引用 XHTML 1.0 DTD,然后通过系统标识符[www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd](http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd)引用。
  • 参考一个内部 DTD,一个嵌入在 XML 文档中的 DTD。内部 DTD 必须出现在方括号中。

清单 10-8 用内部 DTD 呈现了清单 10-1 (减去了<recipe></recipe>标签之间的子元素)。

清单 10-8。带有内部 DTD 的配方文件

<?xml version="1.0"?>
<!DOCTYPE recipe [
   <!ELEMENT recipe (title, ingredients, instructions)>
   <!ELEMENT title (#PCDATA)>
   <!ELEMENT ingredients (ingredient+)>
   <!ELEMENT ingredient (#PCDATA)>
   <!ELEMENT instructions (#PCDATA)>
   <!ATTLIST ingredient qty CDATA "1">
]>
<recipe>
   <!-- Child elements removed for brevity. -->
</recipe>

images 注意一个文档可以有内部和外部 DTDs 比如<!DOCTYPE recipe SYSTEM "http://www.tutortutor.ca/dtds/recipe.dtd" [ <!ELEMENT ...>]>。内部 DTD 被称为内部 DTD 子集,外部 DTD 被称为外部 DTD 子集。任何一个子集都不能覆盖另一个子集的元素声明。

您还可以在 dtd 中声明符号、一般实体和参数实体。符号是一段任意的数据,通常描述未解析的二进制数据的格式,通常具有<!NOTATION *name* SYSTEM *uri*>的形式,其中 name 标识符号, uri 标识某种插件,该插件可以代表解析 XML 文档的应用处理数据。例如,<!NOTATION image SYSTEM "psp.exe">声明了一个名为image的符号,并将 Windows 可执行文件psp.exe标识为处理图像的插件。

通过互联网媒体类型使用符号指定二进制数据类型也很常见(见[en.wikipedia.org/wiki/Internet_media_type](http://en.wikipedia.org/wiki/Internet_media_type))。例如,<!NOTATION image SYSTEM "image/jpeg">声明了一个image符号,它为联合图像专家组图像标识了image/jpeg互联网媒体类型。

通用实体是通过通用实体引用从 XML 文档内部引用的实体,格式为&*name*;的语法结构。例如预定义的ltgtampaposquot角色实体,其&lt;&gt;&amp;&apos;&quot;角色实体引用分别是角色<>&'"的别名。

一般实体分为内部实体和外部实体。内部通用实体是一个通用实体,其值存储在 DTD 中,其形式为<!ENTITY *name* *value*>,其中 name 标识实体, value 指定其值。例如,<!ENTITY copyright "Copyright &copy; 2011 Jeff Friesen. All rights reserved.">声明了一个名为copyright的内部通用实体。这个实体的值可能包括另一个声明的实体,比如&copy;(版权符号的 HTML 实体),并且可以通过指定&copyright;从 XML 文档中的任何地方引用。

外部通用实体是一个通用实体,其值存储在 DTD 之外。该值可能是文本数据(如 XML 文档),也可能是二进制数据(如 JPEG 图像)。外部通用实体分为外部已解析通用实体和外部未解析实体。

外部解析的通用实体引用存储实体文本数据的外部文件,当在文档中指定了通用实体引用时,该文件将被插入到文档中并由验证解析器进行解析,该文件的形式为<!ENTITY *name* SYSTEM *uri*>,其中 name 标识实体, uri 标识外部文件。例如,<!ENTITY chapter-header SYSTEM "http://www.tutortutor.ca/entities/chapheader.xml">chapheader.xml标识为存储要插入到 XML 文档中&chapter-header;出现的任何地方的 XML 内容。可指定替代的<!ENTITY *name* PUBLIC *fpi* *uri*>形式。

images 注意因为外部文件的内容可能会被解析,所以这些内容必须是格式良好的。

外部未解析实体引用存储实体二进制数据的外部文件,格式为<!ENTITY *name* SYSTEM *uri* NDATA *nname*>,其中 name 标识实体, uri 定位外部文件,NDATA标识名为 nname 的符号声明。该符号通常标识用于处理二进制数据或该数据的互联网媒体类型的插件。例如,<!ENTITY photo SYSTEM "photo.jpg" NDATA image>将名称photo与外部二进制文件photo.png和符号image相关联。可以指定替代的<!ENTITY *name* PUBLIC *fpi* *uri* NDATA *nname*>形式。

images 注意 XML 不允许对外部通用实体的引用出现在属性值中。例如,您不能在属性值中指定&chapter-header;

参数实体是通过参数实体引用从 DTD 内部引用的实体,形式为%*name*;的语法结构。它们有助于消除元素声明中的重复内容。例如,您正在为一家大公司创建一个 DTD,这个 DTD 包含三个元素声明:<!ELEMENT salesperson (firstname, lastname)><!ELEMENT lawyer (firstname, lastname)><!ELEMENT accountant (firstname, lastname)>。每个元素都包含重复的子元素内容。如果你需要添加另一个子元素(比如middleinitial,你需要确保所有的元素都被更新;否则,您将面临 DTD 格式错误的风险。参数实体可以帮你解决这个问题。

参数实体分为内部实体和外部实体。内部参数实体是其值存储在 DTD 中的参数实体,其形式为<!ENTITY % *name* *value*>,其中 name 标识实体, value 指定其值。例如,<!ENTITY % person-name "firstname, lastname">用值firstname, lastname声明了一个名为person-name的参数实体。一旦声明,这个实体可以在前面的三个元素声明中被引用,如下:<!ELEMENT salesperson (%person-name;)><!ELEMENT lawyer (%person-name;)><!ELEMENT accountant (%person-name;)>。不是像以前那样将middleinitial添加到salespersonlawyeraccountant中,而是像在<!ENTITY % person-name "firstname, middleinitial, lastname">中那样将这个子元素添加到person-name中,并且这个更改将被应用到这些元素声明中。

外部参数实体是其值存储在 DTD 外部的参数实体。其形式为<!ENTITY % *name* SYSTEM *uri*>,其中 name 标识实体, uri 定位外部文件。例如,<!ENTITY % person-name SYSTEM "http://www.tutortutor.ca/entities/names.dtd">names.dtd标识为存储要插入到 DTD 中%person-name;出现的地方的firstname, lastname文本。可指定替代的<!ENTITY % *name* PUBLIC *fpi* *uri*>形式。

images 这个讨论总结了 DTD 的基础知识。另外一个没有涉及的主题(为了简洁)是条件包含,它允许您指定 DTD 中可供解析器使用的部分,通常与参数实体引用一起使用。

XML 模式

XML 模式是一种语法语言,用于声明 XML 文档的结构、内容和语义(意思)。这种语言的语法文档被称为模式,模式本身就是 XML 文档。模式必须符合 XML 模式 DTD(见[www.w3.org/2001/XMLSchema.dtd](http://www.w3.org/2001/XMLSchema.dtd))。

W3C 引入了 XML Schema 来克服 DTD 的局限性,比如 DTD 缺乏对名称空间的支持。此外,XML Schema 提供了一种面向对象的方法来声明 XML 文档的语法。这种语法语言提供了比 DTD 的 CDATA 和 PCDATA 类型更多的基本类型。例如,您会发现整数、浮点、各种日期和时间以及字符串类型都是 XML 模式的一部分。

images XML Schema 预定义了 19 种原语类型,通过以下标识符表示:anyURIbase64BinarybooleandatedateTimedecimaldoubledurationfloathexBinarygDaygMonthgMonthDaygYeargYearMonthNOTATIONQNamestringtime

XML Schema 提供了限制(通过约束减少允许值的集合)、列表(允许值的序列)和联合(允许从多个类型中选择值)派生方法,用于从这些原始类型创建新的简单类型。比如 XML Schema 通过限制从decimal派生出 13 个整数类型;这些类型通过以下标识符表示:byteintintegerlongnegativeIntegernonNegativeIntegernonPositiveIntegerpositiveIntegershortunsignedByteunsignedIntunsignedLongunsignedShort。它还支持从简单类型创建复杂类型。

熟悉 XML 模式的一个好方法是通过一个例子,比如为清单 10-1 的菜谱语言文档创建一个模式。创建这个配方语言模式的第一步是识别它的所有元素和属性。要素有recipetitleingredientsinstructionsingredientqty是孤属性。

下一步是根据 XML Schema 的内容模型对元素进行分类,内容模型指定了元素中可以包含的子元素和文本节点(参见[en.wikipedia.org/wiki/Node_(computer_science)](http://en.wikipedia.org/wiki/Node_(computer_science)))的类型。当元素没有子元素或文本节点时,该元素被视为,当只接受文本节点时被视为简单,当只接受子元素时被视为复杂,当接受子元素和文本节点时被视为混合。清单 10-1 的元素都没有空的或者混合的内容模型。然而,titleingredientinstructions元素具有简单的内容模型;并且recipeingredients元素具有复杂的内容模型。

对于具有简单内容模型的元素,我们可以区分有属性的元素和没有属性的元素。XML Schema 将具有简单内容模型并且没有属性的元素分类为简单类型。此外,它将具有简单内容模型和属性的元素或者来自其他内容模型的元素分类为复杂类型。此外,XML Schema 将属性分类为简单类型,因为它们只包含文本值——属性没有子元素。清单 10-1 的titleinstructions元素及其qty属性都是简单类型。它的recipeingredientsingredient元素是复杂类型。

至此,我们可以开始声明模式了。以下示例展示了介绍性的schema元素:

<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">

元素介绍了语法。它还将常用的xs名称空间前缀分配给标准 XML 模式名称空间;xs:随后被添加到 XML 模式元素名称的前面。

接下来,我们使用element元素来声明titleinstructions简单类型元素,如下所示:

<xs:element name="title" type="xs:string"/>
<xs:element name="instructions" type="xs:string"/>

XML Schema 要求每个元素都有一个名称,并且(与 DTD 不同)与一个类型相关联,该类型标识元素中存储的数据类型。例如,第一个element声明通过其name属性将title标识为名称,通过其type属性将string标识为类型(字符串或字符数据出现在<title></title>标记之间)。xs:string中的xs:前缀是必需的,因为string是预定义的 W3C 类型。

继续,我们现在使用attribute元素来声明qty简单类型属性,如下所示:

<xs:attribute name="qty" type="xs:unsignedInt" default="1"/>

这个attribute元素声明了一个名为qty的属性。我选择了unsignedInt作为这个属性的type,因为数量是非负值。此外,我已经指定了1作为未指定qty时的default值— attribute元素默认声明可选属性。

images 注意元素和属性声明的顺序在模式中并不重要。

既然我们已经声明了简单类型,我们可以开始声明复杂类型了。首先,我们将声明recipe,如下所示:

<xs:element name="recipe">
   <xs:complexType>
      <xs:sequence>
         <xs:element ref="title"/>
         <xs:element ref="ingredients"/>
         <xs:element ref="instructions"/>
      </xs:sequence>
   </xs:complexType>
</xs:element>

该声明声明recipe是一个复杂类型(通过complexType元素),由一个title元素、一个ingredients元素和一个instructions元素组成(通过sequence元素)。这些元素中的每一个都由一个不同的element声明,这个不同的element由它的elementref属性引用。

下一个要声明的复杂类型是ingredients。下面的示例提供了它的声明:

<xs:element name="ingredients">
   <xs:complexType>
      <xs:sequence>
         <xs:element ref="ingredient" maxOccurs="unbounded"/>
      </xs:sequence>
   </xs:complexType>
</xs:element>

这个声明声明ingredients是一个复杂类型,由一个或多个ingredient元素组成。“或更多”是通过包含elementmaxOccurs属性并将该属性的值设置为unbounded来指定的。

images 注意maxOccurs属性标识一个元素可以出现的最大次数。一个类似的minOccurs属性标识了一个元素出现的最小次数。每个属性可以被赋予 0 或正整数。此外,您可以为maxOccurs指定unbounded,这意味着元素的出现次数没有上限。每个属性的默认值为 1,这意味着当两个属性都不存在时,一个元素只能出现一次。

最后要声明的复杂类型是ingredient。虽然ingredient只能包含文本节点,这意味着它应该是一个简单的类型,但正是qty属性的存在使它变得复杂。查看以下声明:

<xs:element name="ingredient">
   <xs:complexType>
      <xs:simpleContent>
         <xs:extension base="xs:string">
            <xs:attribute ref="qty"/>
         </xs:extension>
      </xs:simpleContent>
   </xs:complexType>
</xs:element>

名为ingredient的元素是一个复杂类型(因为它有可选的qty属性)。simpleContent元素表示ingredient只能包含简单的内容(文本节点),extension元素表示ingredient是一个新类型,扩展了预定义的string类型(通过base属性指定),意味着ingredient继承了string的所有属性和结构。此外,ingredient被赋予了一个附加的qty属性。

清单 10-9 将前面的例子合并成一个完整的模式。

清单 10-9。配方文档的模式

<?xml version="1.0"?> <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> <xs:element name="title" type="xs:string"/> <xs:element name="instructions" type="xs:string"/> <xs:attribute name="qty" type="xs:unsignedInt" default="1"/> <xs:element name="recipe">    <xs:complexType>       <xs:sequence>          <xs:element ref="title"/>          <xs:element ref="ingredients"/>          <xs:element ref="instructions"/>       </xs:sequence>    </xs:complexType> </xs:element> <xs:element name="ingredients">    <xs:complexType>       <xs:sequence>          <xs:element ref="ingredient" maxOccurs="unbounded"/>       </xs:sequence>    </xs:complexType> </xs:element> <xs:element name="ingredient">    <xs:complexType>       <xs:simpleContent>          <xs:extension base="xs:string">             <xs:attribute ref="qty"/>          </xs:extension>       </xs:simpleContent>    </xs:complexType> </xs:element>

创建模式后,您会希望从一个食谱文档中引用它。通过在文档的根元素开始标记(<recipe>)上指定xmlns:xsixsi:schemaLocation属性来完成这个任务,如下所示:

<recipe
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://www.tutortutor.ca/schemas recipe.xsd">

xmlns属性将[www.tutortutor.ca/](http://www.tutortutor.ca/)标识为文档的默认名称空间。无前缀的元素及其无前缀的属性属于此命名空间。

xmlns:xsi属性将传统的xsi (XML 模式实例)前缀与标准的[www.w3.org/2001/XMLSchema-instance](http://www.w3.org/2001/XMLSchema-instance)名称空间相关联。文档中唯一以xsi:为前缀的项目是schemaLocation

schemaLocation属性用于定位模式。该属性的值可以是多对空格分隔的值,但在本例中被指定为一对这样的值。第一个值([www.tutortutor.ca/schemas](http://www.tutortutor.ca/schemas))标识模式的目标名称空间,第二个值(recipe.xsd)标识模式在这个名称空间中的位置。

images 注意符合 XML Schema 语法的模式文件通常被指定为.xsd文件扩展名。

如果一个 XML 文档声明了一个名称空间(xmlns default 或xmlns:*prefix*),那么这个名称空间必须对模式可用,以便验证解析器可以解析对该名称空间的元素和其他模式组件的所有引用。我们还需要提到模式描述了哪个名称空间,我们通过在schema元素上包含targetNamespace属性来做到这一点。例如,假设我们的配方文档声明了一个默认的 XML 名称空间,如下所示:

<?xml version="1.0"?>
<recipe >

至少,我们需要修改清单 10-9 的schema元素,以包含targetNameSpace和菜谱文档的默认名称空间作为targetNameSpace的值,如下所示:

<xs:schema targetNamespace="http://www.tutortutor.ca/"
           xmlns:xs="http://www.w3.org/2001/XMLSchema">

也许您想知道为什么您需要学习 XML Schema,而 DTD 对于您的 XML 项目来说已经足够好了。学习 XML 模式的原因是第十一章向您介绍了基于 XML 的 Web 服务描述语言(WSDL ),在那一章中给出的 WSDL 的例子包括了一个基于 XML 模式的模式。

用 SAX 解析 XML 文档

XML 的简单 API(SAX)是一个基于事件的 API,用于从开始到结束顺序解析 XML 文档。当面向 SAX 的解析器遇到来自文档的信息集(描述 XML 文档信息的抽象数据模型——参见[en.wikipedia.org/wiki/XML_Information_Set](http://en.wikipedia.org/wiki/XML_Information_Set))的项目时,它通过调用应用的一个处理程序(解析器调用其方法以提供事件信息的对象)中的一个方法,将该项目作为事件提供给应用,应用之前已经向解析器注册了该处理程序。然后,应用可以通过以某种方式处理 infoset 项来使用这个事件。

images 据其官网([www.saxproject.org/](http://www.saxproject.org/))介绍,SAX 起源于 Java 的 XML 解析 API。然而,SAX 并不是 Java 的专利。微软也支持 SAX。NET 框架(见[saxdotnet.sourceforge.net/](http://saxdotnet.sourceforge.net/))。

在浏览了 SAX API 之后,本节提供了该 API 的简单演示,以帮助您熟悉其基于事件的解析范例。然后向您展示如何创建自定义实体解析器。

探索 SAX API

SAX 有两个主要版本。Java 通过javax.xml.parsers包的抽象SAXParserSAXParserFactory类实现 SAX 1,通过org.xml.sax包的XMLReader接口和org.xml.sax.helpers包的XMLReaderFactory类实现 SAX 2。org.xml.saxorg.xml.sax.extorg.xml.sax.helpers包提供了各种类型来增强这两种 Java 实现。

images 注意我只研究 SAX 2 的实现,因为 SAX 2 提供了关于 XML 文档的附加信息集项目(比如注释和 CDATA 节通知)。

实现XMLReader接口的类描述了基于 SAX 2 的解析器。这些类的实例是通过调用XMLReaderFactory类的createXMLReader()方法获得的。例如,下面的示例调用该类的static XMLReader createXMLReader()方法来创建并返回一个XMLReader实例:

XMLReader xmlr = XMLReaderFactory.createXMLReader();

这个方法调用返回一个XMLReader实现类的实例,并将其引用分配给xmlr

images 注意在幕后,createXMLReader()试图从系统默认值创建一个XMLReader实例,根据查找过程,首先检查org.xml.sax.driver系统属性以查看它是否有值。如果是这样,这个属性的值被用作实现XMLReader的类的名称,并尝试实例化这个类并返回实例。当createXMLReader()不能获得一个合适的类或者实例化该类时,抛出org.xml.sax.SAXException类的一个实例。

返回的XMLReader对象提供了几种配置解析器和解析文档内容的方法。这些方法描述如下:

  • ContentHandler getContentHandler()返回当前的内容处理程序,它是一个实现org.xml.sax.ContentHandler接口的类的实例,或者当没有注册时返回空引用。
  • DTDHandler getDTDHandler()返回当前的 DTD 处理程序,它是实现org.xml.sax.DTDHandler接口的类的实例,或者当没有注册时返回空引用。
  • EntityResolver getEntityResolver()返回当前实体解析器,它是实现org.xml.sax.EntityResolver接口的类的实例,或者当没有注册时返回空引用。
  • ErrorHandler getErrorHandler()返回当前的错误处理程序,它是实现org.xml.sax.ErrorHandler接口的类的实例,或者当没有注册时返回空引用。
  • boolean getFeature(String name)返回对应于由name标识的特征的布尔值,该值必须是全限定的 URI。当名字没有被识别为特性时,这个方法抛出org.xml.sax.SAXNotRecognizedException,当名字被识别但在调用getFeature()时不能确定关联值时,抛出org.xml.sax.SAXNotSupportedExceptionSAXNotRecognizedExceptionSAXNotSupportedExceptionSAXException的子类。
  • Object getProperty(String name)返回对应于由name标识的属性的java.lang.Object实例,该属性必须是完全限定的 URI。当名字没有被识别为属性时,这个方法抛出SAXNotRecognizedException,当名字被识别但在调用getProperty()时不能确定相关值时,抛出SAXNotSupportedException
  • 解析 XML 文档,直到文档被解析后才返回。input参数存储了对一个org.xml.sax.InputSource实例的引用,该实例描述了文档的来源(比如一个java.io.InputStream实例,或者甚至是一个基于java.lang.String的系统标识符 URI)。当无法读取源代码时,该方法抛出java.io.IOException,当解析失败时抛出SAXException,这可能是由于违反了良好格式。
  • void parse(String systemId)通过执行parse(new InputSource(systemId));来解析 XML 文档。
  • void setContentHandler(ContentHandler handler)向解析器注册由handler标识的内容处理器。ContentHandler接口提供了 11 个回调方法,调用这些方法来报告各种解析事件(比如元素的开始和结束)。
  • void setDTDHandler(DTDHandler handler)向解析器注册由handler标识的 DTD 处理程序。DTDHandler接口提供了一对回调方法,用于报告符号和外部未解析的实体。
  • void setEntityResolver(EntityResolver resolver)向解析器注册由resolver标识的实体解析器。EntityResolver接口为解析实体提供了单一的回调方法。
  • void setErrorHandler(ErrorHandler handler)向解析器注册由handler标识的错误处理程序。ErrorHandler接口提供了三个回调方法,用于报告致命错误(阻止进一步解析的问题,比如违反良构性);可恢复错误(不会阻止进一步解析的问题,比如验证失败),以及警告(不需要解决的错误,比如在元素名称前加上 W3C 保留的前缀xml)。
  • void setFeature(String name, boolean value)value分配给由name识别的特征,该特征必须是完全合格的 URI。当名字没有被识别为特性时,这个方法抛出SAXNotRecognizedException,当名字被识别但在调用setFeature()时不能设置相关值时,抛出SAXNotSupportedException
  • void setProperty(String name, Object value)value分配给name标识的房产,该房产必须是完全合格的 URI。当名字没有被识别为属性时,这个方法抛出SAXNotRecognizedException,当名字被识别但在调用setProperty()时不能设置相关值时,抛出SAXNotSupportedException

如果没有安装处理程序,所有与该处理程序相关的事件都会被忽略。不安装错误处理程序可能会有问题,因为正常的处理可能无法继续,应用也不会意识到有什么地方出错了。如果没有安装实体解析器,解析器将执行自己的默认解析。在这一章的后面我会有更多关于实体解析的内容。

images 注意您可以在解析文档时安装新的内容处理程序、DTD 处理程序、实体解析程序或错误处理程序。当下一个事件发生时,解析器开始使用处理程序。

获得一个XMLReader实例后,您可以通过设置其特性和属性来配置该实例。一个特性是描述解析器模式的名称-值对,比如验证。相比之下,属性是一个名称-值对,它描述了解析器接口的一些其他方面,比如一个词法处理程序,它通过提供用于报告注释、CDATA 分隔符和一些其他语法结构的回调方法来扩充内容处理程序。

特性和属性有名称,名称必须是以http://前缀开头的绝对 URIs。一个特性的值总是一个布尔真/假值。相反,属性值是一个任意的对象。下面的示例演示如何设置功能和属性:

xmlr.setFeature("http://xml.org/sax/features/validation", true);
xmlr.setProperty("http://xml.org/sax/properties/lexical-handler",
                 new LexicalHandler() { /* … */ });

setFeature()调用启用了validation特性,以便解析器执行验证。特征名称以[xml.org/sax/features/](http://xml.org/sax/features/)为前缀。

images 注意解析器必须支持namespacesnamespace-prefixes特性。namespaces决定是否将 URIs 和本地名字传递给ContentHandlerstartElement()endElement()方法。默认为true—这些名字都是经过的。当false时,解析器可以传递空字符串。namespace-prefixes决定名称空间声明的xmlnsxmlns:*prefix*属性是否包含在传递给startElement()Attributes列表中,还决定限定名是否作为方法的第三个参数传递——一个限定名是一个前缀加一个本地名。默认为false,意思是不包含xmlnsxmlns:*prefix*,解析器不用传递限定名。没有强制的属性。JDK 文档的org.xml.sax包页面列出了标准的 SAX 2 特性和属性。

setProperty()调用将实现org.xml.sax.ext.LexicalHandler接口的类的一个实例分配给lexical-handler属性,这样就可以调用接口方法来报告注释、CDATA 部分等等。属性名以[xml.org/sax/properties/](http://xml.org/sax/properties/)为前缀。

images 注意ContentHandlerDTDHandlerEntityResolverErrorHandler不同,LexicalHandler是一个扩展(它不是核心 SAX API 的一部分),这就是为什么XMLReader没有声明一个void setLexicalHandler(LexicalHandler handler)方法。如果你想安装一个词法处理程序,你必须使用XMLReadersetProperty()方法来安装这个处理程序作为[xml.org/sax/properties/lexical-handler](http://xml.org/sax/properties/lexical-handler)属性的值。

要素和属性可以是只读的,也可以是读写的。(在极少数情况下,功能或属性可能是只写的。)当设置或读取特性或属性时,可能会抛出SAXNotSupportedExceptionSAXNotRecognizedException实例。例如,如果你试图修改一个只读的特征/属性,就会抛出一个SAXNotSupportedException类的实例。如果在解析过程中调用setFeature()setProperty(),也会抛出这个异常。试图为不执行验证的解析器设置验证特性是一种抛出SAXNotRecognizedException类实例的情况。

setContentHandler()setDTDHandler()setErrorHandler()安装的处理程序,由setEntityResolver()安装的实体解析器,以及由lexical-handler属性/ LexicalHandler接口安装的处理程序提供了各种回调方法,您需要理解这些方法,然后才能对它们进行编码以有效地响应解析事件。ContentHandler声明了以下面向内容的信息回调方法:

  • void characters(char[] ch, int start, int length)通过ch数组报告一个元素的字符数据。传递给startlength的参数标识了数组中与该方法调用相关的部分。字符通过一个char[]数组传递,而不是通过一个String实例来优化性能。解析器通常将大量文档存储在一个数组中,并反复将对该数组的引用以及更新后的startlength值传递给characters()
  • void endDocument()报告已到达文档的结尾。应用可能会使用此方法来关闭输出文件或执行一些其他清理。
  • void endElement(String uri, String localName, String qName)报告已经到达元素的结尾。uri标识元素的命名空间 URI,或者当没有命名空间 URI 或命名空间处理尚未启用时为空。localName标识元素的本地名称,即没有前缀的名称(例如htmlh:html中的html)。qName引用限定名;比如没有前缀时的h:html或者html。当检测到结束标签时,调用endElement(),或者当检测到空元素标签时,紧接着startElement()调用。
  • void endPrefixMapping(String prefix)报告已经到达名称空间前缀映射的结尾(例如,xmlns:h),并且prefix报告这个前缀(例如,h)。
  • void ignorableWhitespace(char[] ch, int start, int length)报告可忽略的空格(位于标签之间的空格,DTD 不允许混合内容)。这个空白通常用于缩进标签。这些参数的作用与characters()方法中的参数相同。
  • void processingInstruction(String target, String data)报告一个处理指令,其中target标识指令指向的应用,data提供指令的数据(无数据时为空引用)。
  • void setDocumentLocator(Locator locator)报告一个org.xml.sax.Locator对象(一个实现Locator接口的类的实例),其int getColumnNumber()int getLineNumber()String getPublicId()String getSystemId()方法可以被调用来获得任何文档相关事件结束位置的位置信息,即使解析器没有报告错误。这个方法在startDocument()之前被调用,是保存Locator对象的好地方,这样就可以从其他回调方法中访问它。
  • void skippedEntity(String name)报告所有跳过的实体。验证解析器解析所有一般的实体引用,但是非验证解析器可以选择跳过它们,因为非验证解析器不读取声明这些实体的 dtd。如果非验证解析器不读取 DTD,它就不知道实体是否被正确声明。非验证解析器不是试图读取 DTD 并报告实体的替换文本,而是用实体的名称调用skippedEntity()
  • void startDocument()报告已到达文档的开头。应用可能使用此方法来创建输出文件或执行一些其他初始化。
  • void startElement(String uri, String localName, String qName, Attributes attributes)报告已经到达元素的开始。uri标识元素的命名空间 URI,或者当没有命名空间 URI 或命名空间处理尚未启用时为空。localName标识元素的本地名,qName引用其限定名,attributes引用标识元素属性的org.xml.sax.Attribute对象的数组——当没有属性时,该数组为空。当检测到开始标签或空元素标签时,调用startElement()
  • void startPrefixMapping(String prefix, String uri)报告已经到达名称空间前缀映射的开始(xmlns:h="http://www.w3.org/1999/xhtml",例如,】报告该前缀(例如,h),uri报告该前缀映射到的 URI(例如,[www.w3.org/1999/xhtml](http://www.w3.org/1999/xhtml))。

除了setDocumentLocator()之外的每个方法都被声明为抛出SAXException,覆盖回调方法可能会选择在检测到问题时抛出。

DTDHandler声明了以下面向 DTD 的信息回调方法:

  • void notationDecl(String name, String publicId, String systemId)报告一个批注声明,其中name提供该声明的name属性值,publicId提供该声明的public属性值(该值不可用时为空引用),systemId提供该声明的system属性值。
  • void unparsedEntityDecl(String name, String publicId, String systemId, String notationName)报告一个外部未解析的实体声明,其中name提供该声明的name属性的值,publicId提供public属性的值(该值不可用时为空引用),systemId提供system属性的值,notationName提供NDATA名称。

每个方法都被声明为抛出SAXException,重载的回调方法可能会选择在检测到问题时抛出。

EntityResolver声明了下面的回调方法:

  • 调用InputSource resolveEntity(String publicId, String systemId)让应用通过返回基于不同 URI 的自定义InputSource实例来解析外部实体(比如外部 DTD 子集)。该方法被声明为在检测到面向 SAX 的问题时抛出SAXException,还被声明为在遇到 I/O 错误时抛出IOException,这可能是为了响应为正在创建的InputSource创建一个InputStream实例或java.io.Reader实例。

ErrorHandler声明了以下面向错误的信息回调方法:

  • void error(SAXParseException exception)报告发生了可恢复的解析器错误(通常文档无效);细节通过传递给exception的参数指定。该方法通常被覆盖,通过命令窗口报告错误(见第一章)或将其记录到文件或数据库中。
  • void fatalError(SAXParseException exception)报告发生了不可恢复的解析器错误(文档格式不正确);细节通过传递给exception的参数指定。此方法通常被重写,以便应用可以在停止处理文档之前记录错误(因为文档不再可靠)。
  • void warning(SAXParseException e)报告发生了非错误(例如,元素名称以保留的xml字符序列开始);细节是通过传递给exception的参数指定的。此方法通常被重写以通过控制台报告警告,或者将其记录到文件或数据库中。

每个方法都被声明为抛出SAXException,重载的回调方法可能会选择在检测到问题时抛出。

LexicalHandler声明了以下附加的面向内容的信息回调方法:

  • void comment(char[] ch, int start, int length)通过ch数组报告注释。传递给startlength的参数标识了数组中与该方法调用相关的部分。
  • void endCDATA()报告 CDATA 段的结尾。
  • void endDTD()报告 DTD 的结束。
  • void endEntity(String name)报告由name标识的实体的开始。
  • void startCDATA()报告 CDATA 段的开始。
  • void startDTD(String name, String publicId, String systemId)报告由name识别的 DTD 的开始。publicId指定外部 DTD 子集声明的公共标识符,或者当没有声明时为空引用。类似地,systemId为外部 DTD 子集指定声明的系统标识符,或者当没有声明时为空引用。
  • void startEntity(String name)报告由name标识的实体的开始。

每个方法都被声明为抛出SAXException,重载的回调方法可能会选择在检测到问题时抛出。

因为在每个接口中实现所有方法可能很繁琐,所以 SAX API 方便地提供了org.xml.sax.helpers.DefaultHandler适配器类来减轻您的负担。DefaultHandler实现ContentHandlerDTDHandlerEntityResolverErrorHandler。SAX 也提供了org.xml.sax.ext.DefaultHandler2,它继承了DefaultHandler,也实现了LexicalHandler

演示 SAX API

我创建了一个SAXDemo应用来演示 SAX API。该应用由一个SAXDemo入口点类和一个DefaultHandler2Handler子类组成。清单 10-10 向SAXDemo展示源代码。

清单 10-10。SAXDemo

`import java.io.FileReader;
import java.io.IOException;

import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;

import org.xml.sax.helpers.XMLReaderFactory;

class SAXDemo
{
   public static void main(String[] args)
   {
      if (args.length < 1 || args.length > 2)
      {
         System.err.println("usage: java SAXDemo xmlfile [v]");
         return;
      }
      try
      {
         XMLReader xmlr = XMLReaderFactory.createXMLReader();
         if (args.length == 2 && args[1].equals("v"))
            xmlr.setFeature("http://xml.org/sax/features/validation", true);
         xmlr.setFeature("http://xml.org/sax/features/namespace-prefixes",                          true);
         Handler handler = new Handler();
         xmlr.setContentHandler(handler);
         xmlr.setDTDHandler(handler);
         xmlr.setEntityResolver(handler);
         xmlr.setErrorHandler(handler);
         xmlr.setProperty("http://xml.org/sax/properties/lexical-handler", handler);
         xmlr.parse(new InputSource(new FileReader(args[0])));
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (SAXException saxe)
      {
         System.err.println("SAXE: "+saxe);
      }
   }
}`

SAXDemo将从命令行运行。在确认已经指定了一两个命令行参数(XML 文档的名称,后面可选地跟着小写字母 v,它告诉SAXDemo创建一个验证解析器)之后,main()创建一个XMLReader实例;有条件地启用validation功能,并启用namespace-prefixes功能;实例化伴生的Handler类;安装这个Handler实例作为解析器的内容处理程序、DTD 处理程序、实体解析程序和错误处理程序。安装这个Handler实例作为lexical-handler属性的值;创建输入源以从文件中读取文档;并解析文档。

Handler类的源代码显示在清单 10-11 中。

清单 10-11。Handler

`import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXParseException;

import org.xml.sax.ext.DefaultHandler2;

class Handler extends DefaultHandler2
{
   private Locator locator;
   @Override
   public void characters(char[] ch, int start, int length)
   {
      System.out.print("characters() [");
      for (int i = start; i < start+length; i++)
         System.out.print(ch[i]);
      System.out.println("]");
   }
   @Override
   public void comment(char[] ch, int start, int length) {
      System.out.print("characters() [");
      for (int i = start; i < start+length; i++)
         System.out.print(ch[i]);
      System.out.println("]");
   }
   @Override
   public void endCDATA()
   {
      System.out.println("endCDATA()");
   }
   @Override
   public void endDocument()
   {
      System.out.println("endDocument()");
   }
   @Override
   public void endDTD()
   {
      System.out.println("endDTD()");
   }
   @Override
   public void endElement(String uri, String localName, String qName)
   {
      System.out.print("endElement() ");
      System.out.print("uri=["+uri+"], ");
      System.out.print("localName=["+localName+"], ");
      System.out.println("qName=["+qName+"]");
   }
   @Override
   public void endEntity(String name)
   {
      System.out.print("endEntity() ");
      System.out.println("name=["+name+"]");
   }
   @Override
   public void endPrefixMapping(String prefix)
   {
      System.out.print("endPrefixMapping() ");
      System.out.println("prefix=["+prefix+"]");
   }
   @Override
   public void error(SAXParseException saxpe)
   {
      System.out.println("error() "+saxpe);
   }
   @Override
   public void fatalError(SAXParseException saxpe)
   {
      System.out.println("fatalError() "+saxpe);
   }
   @Override public void ignorableWhitespace(char[] ch, int start, int length)
   {
      System.out.print("ignorableWhitespace() [");
      for (int i = start; i < start+length; i++)
         System.out.print(ch[i]);
      System.out.println("]");
   }
   @Override
   public void notationDecl(String name, String publicId, String systemId)
   {
      System.out.print("notationDecl() ");
      System.out.print("name=["+name+"]");
      System.out.print("publicId=["+publicId+"]");
      System.out.println("systemId=["+systemId+"]");
   }
   @Override
   public void processingInstruction(String target, String data)
   {
      System.out.print("processingInstruction() [");
      System.out.println("target=["+target+"]");
      System.out.println("data=["+data+"]");
   }
   @Override
   public InputSource resolveEntity(String publicId, String systemId)
   {
      System.out.print("resolveEntity() ");
      System.out.print("publicId=["+publicId+"]");
      System.out.println("systemId=["+systemId+"]");
      // Do not perform a remapping.
      InputSource is = new InputSource();
      is.setPublicId(publicId);
      is.setSystemId(systemId);
      return is;
   }
   @Override
   public void setDocumentLocator(Locator locator)
   {
      System.out.print("setDocumentLocator() ");
      System.out.println("locator=["+locator+"]");
      this.locator = locator;
   }
   @Override
   public void skippedEntity(String name)
   {
      System.out.print("skippedEntity() ");
      System.out.println("name=["+name+"]");
   }
   @Override
   public void startCDATA()
   {
      System.out.println("startCDATA()");
   }` `@Override
   public void startDocument()
   {
      System.out.println("startDocument()");
   }
   @Override
   public void startDTD(String name, String publicId, String systemId)
   {
      System.out.print("startDTD() ");
      System.out.print("name=["+name+"]");
      System.out.print("publicId=["+publicId+"]");
      System.out.println("systemId=["+systemId+"]");
   }
   @Override
   public void startElement(String uri, String localName, String qName,
                            Attributes attributes)
   {
      System.out.print("startElement() ");
      System.out.print("uri=["+uri+"], ");
      System.out.print("localName=["+localName+"], ");
      System.out.println("qName=["+qName+"]");
      for (int i = 0; i < attributes.getLength(); i++)
         System.out.println("  Attribute: "+attributes.getLocalName(i)+", "+
                            attributes.getValue(i));
      System.out.println("Column number=["+locator.getColumnNumber()+"]");
      System.out.println("Line number=["+locator.getLineNumber()+"]");
   }
   @Override
   public void startEntity(String name)
   {
      System.out.print("startEntity() ");
      System.out.println("name=["+name+"]");
   }
   @Override
   public void startPrefixMapping(String prefix, String uri)
   {
      System.out.print("startPrefixMapping() ");
      System.out.print("prefix=["+prefix+"]");
      System.out.println("uri=["+uri+"]");
   }
   @Override
   public void unparsedEntityDecl(String name, String publicId,
                                  String systemId, String notationName)
   {
      System.out.print("unparsedEntityDecl() ");
      System.out.print("name=["+name+"]");
      System.out.print("publicId=["+publicId+"]");
      System.out.print("systemId=["+systemId+"]");
      System.out.println("notationName=["+notationName+"]");
   }
   @Override
   public void warning(SAXParseException saxpe)` `   {
      System.out.println("warning() "+saxpe);
   }
}`

Handler子类非常简单;它根据特性和属性设置输出关于 XML 文档的每一条可能的信息。您会发现这个类对于探索事件发生的顺序以及各种特性和属性非常方便。

编译完Handler的源代码后,执行java SAXDemo svg-examples.xml(见清单 10-4 )。SAXDemo通过显示以下输出做出响应:

setDocumentLocator()
locator=com.sun.org.apache.xerces.internal.parsers.  ![ImagesAbstractSAXParser$LocatorProxy@1f98d58]
startDocument()
startElement() uri=[], localName=[svg-examples], qName=[svg-examples]
Column number=[15]
Line number=[2]
characters() [
   ]
startElement() uri=[], localName=[example], qName=[example]
Column number=[13]
Line number=[3]
characters() [
      The following Scalable Vector Graphics document describes a blue-filled and ]
characters() [
      black-stroked rectangle.
      ]
startCDATA()
characters() [<svg width="100%" height="100%" version="1.1"
           >
         <rect width="300" height="100"
               style="fill:rgb(0,0,255);stroke-width:1; stroke:rgb(0,0,0)"/>
      </svg>]
endCDATA()
characters() [
   ]
endElement() uri=[], localName=[example], qName=[example]
characters() [
]
endElement() uri=[], localName=[svg-examples], qName=[svg-examples]
endDocument()

第一个输出行(@1f98d58值可能会不同)证明了setDocumentLocator()被首先调用。它还标识了Locator实例,当startElement()被调用时,其getColumnNumber()getLineNumber()方法被调用以输出解析器位置——这些方法返回从 1 开始的列号和行号。

也许您对以下输出的三个实例感到好奇:

characters() [
   ]

跟在endCDATA()输出后面的这个输出的实例报告了一个回车/换行符组合,这个组合没有包含在前面的character()方法调用中,传递给它的是 CDATA 部分的内容减去这些行结束符。相比之下,在对svg-examplesstartElement()调用之后和对exampleendElement()调用之后的输出实例就有些奇怪了。<svg-examples><example>之间没有内容,</example></svg-examples>之间也没有内容,还是有?

您可以通过修改svg-examples.xml来包含一个内部 DTD 来满足这种好奇心。在 XML 声明和<svg-examples>开始标记之间放置下面的 DTD(表示一个svg-element包含一个或多个example元素,一个example元素包含解析的字符数据):

<!DOCTYPE svg-examples [
<!ELEMENT svg-examples (example+)>
<!ELEMENT example (#PCDATA)>
]>

继续,执行java SAXDemo svg-examples.html。这一次,您应该会看到以下输出:

setDocumentLocator()
locator=com.sun.org.apache.xerces.internal.parsers.  ![ImagesAbstractSAXParser$LocatorProxy@1f98d58]
startDocument()
startDTD() name=[svg-examples]publicId=[null]systemId=[null]
endDTD()
startElement() uri=[], localName=[svg-examples], qName=[svg-examples]
Column number=[15]
Line number=[6]
ignorableWhitespace() [
   ]
startElement() uri=[], localName=[example], qName=[example]
Column number=[13]
Line number=[7]
characters() [
      The following Scalable Vector Graphics document describes a blue-filled and
      black-stroked rectangle.]
characters() [
      ]
startCDATA()
characters() [<svg width="100%" height="100%" version="1.1"
           >
         <rect width="300" height="100"
               style="fill:rgb(0,0,255);stroke-width:1; stroke:rgb(0,0,0)"/>
      </svg>]
endCDATA()
characters() [
   ]
endElement() uri=[], localName=[example], qName=[example]
ignorableWhitespace() [
]
endElement() uri=[], localName=[svg-examples], qName=[svg-examples]
endDocument()

这个输出表明ignorableWhitespace()方法是在startElement()之后为svg-examples调用的,在endElement()之后为example调用的。产生奇怪输出的前两个对characters()的调用报告了可忽略的空白。

回想一下,我之前将可忽略空白定义为 DTD 不允许混合内容的标签之间的空白。例如,DTD 指出svg-examples应该只包含example元素,不包含example元素和解析的字符数据。然而,<svg-examples>标签后面的行结束符和<example>前面的前导空格是解析的字符数据。解析器现在通过调用ignorableWhitespace()来报告这些字符。

这一次,以下输出只出现了两次:

characters() [

]

第一次出现时,将行结束符与example元素的文本分开报告(在 CDATA 部分之前);它以前没有这样做,这证明了用元素的全部或部分内容调用了characters()。再次,第二次出现报告 CDATA 部分后面的行结束符。

假设您想要验证svg-examples.xml,而不需要之前给出的内部 DTD。如果您尝试这样做(通过执行java SAXDemo svg-examples.xml v),您会在它的输出中发现类似于下面所示的几行:

error() org.xml.sax.SAXParseException; lineNumber: 2; columnNumber: 14; Document is![Images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) invalid:
no grammar found.
error() org.xml.sax.SAXParseException; lineNumber: 2; columnNumber: 14; Document root![Images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
element "svg-examples", must match DOCTYPE root "null".

这几行显示没有找到 DTD 语法。此外,解析器报告了svg-examples(它认为第一个遇到的元素是根元素)和null(它认为在没有 DTD 的情况下null是根元素的名称)之间的不匹配。这两种违规都不被认为是致命的,这就是为什么叫error()而不是fatalError()的原因。

将内部 DTD 添加到svg-examples.xml,并重新执行java SAXDemo svg-examples.xml v。这一次,您应该在输出中看不到带有error()前缀的行。

images 注意 SAX 2 验证默认为根据 DTD 进行验证。相反,要验证基于 XML 模式的模式,需要将带有[www.w3.org/2001/XMLSchema](http://www.w3.org/2001/XMLSchema)值的schemaLanguage属性添加到XMLReader实例中。通过在xmlr.parse(new InputSource(new FileReader(args[0])));之前指定xmlr.setProperty("http://java.sun.com/xml/jaxp/properties/schemaLanguage", "http://www.w3.org/2001/XMLSchema");,为SAXDemo完成该任务。

创建自定义实体解析器

在探索 XML 时,我向您介绍了实体的概念,它们是别名数据。然后,我讨论了一般实体和参数实体的内部和外部变体。

不同于其值在 DTD 中指定的内部实体,外部实体的值在 DTD 之外指定,并通过公共和/或系统标识符来标识。系统标识符是 URI,而公共标识符是正式的公共标识符。

XML 解析器通过连接到适当系统标识符的InputSource实例读取外部实体(包括外部 DTD 子集)。在许多情况下,您向解析器传递一个系统标识符或InputSource实例,让解析器发现在哪里可以找到从当前文档实体引用的其他实体。

但是,出于性能或其他原因,您可能希望解析器从不同的系统标识符中读取外部实体的值,例如本地 DTD 副本的系统标识符。您可以通过创建一个使用公共标识符选择不同系统标识符的实体解析器来完成这项任务。当遇到外部实体时,解析器调用自定义实体解析器来获取这个标识符。

考虑清单 10-12 对清单 10-1 的烤奶酪三明治配方的正式说明。

清单 10-12。用食谱标记语言指定的基于 XML 的烤奶酪三明治食谱

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE recipeml PUBLIC "-//FormatData//DTD RecipeML 0.5//EN"
                          "http://www.formatdata.com/recipeml/recipeml.dtd">
<recipeml version="0.5">
   <recipe>
      <head>
         <title>Grilled Cheese Sandwich</title>
      </head>
      <ingredients>
         <ing>
            <amt><qty>2</qty><unit>slice</unit></amt>
            <item>bread</item>
         </ing>
         <ing>
            <amt><qty>1</qty><unit>slice</unit></amt>
            <item>cheese</item>
         </ing>
         <ing>
            <amt><qty>2</qty><unit>pat</unit></amt>
            <item>margarine</item>
         </ing>
      </ingredients>
      <directions>
         <step>Place frying pan on element and select medium heat.</step>
         <step>For each bread slice, smear one pat of margarine on one side of
               bread slice.</step>
         <step>Place cheese slice between bread slices with margarine-smeared
               sides away from the cheese.</step>
         <step>Place sandwich in frying pan with one margarine-smeared size in
               contact with pan.</step>
         <step>Fry for a couple of minutes and flip.</step>
         <step>Fry other side for a minute and serve.</step>
      </directions>
   </recipe>
</recipeml>

清单 10-12 用菜谱标记语言(RecipeML) 指定了烤奶酪三明治菜谱,这是一种基于 XML 的菜谱标记语言。(一家名为 FormatData 【参见[www.formatdata.com/](http://www.formatdata.com/))的公司在 2000 年发布了这种格式。)

文档类型声明将-//FormatData//DTD RecipeML 0.5//EN报告为正式的公共标识符,将[www.formatdata.com/recipeml/recipeml.dtd](http://www.formatdata.com/recipeml/recipeml.dtd)报告为系统标识符。让我们将这个正式的公共标识符映射到recipeml.dtd,一个 DTD 文件本地副本的系统标识符,而不是保留默认的映射。

为了创建一个定制的实体解析器来执行这个映射,我们声明了一个类,该类根据它的InputSource resolveEntity(String publicId, String systemId)方法实现了EntityResolver接口。然后,我们使用传递的publicId值作为指向所需systemId值的映射的键,然后使用这个值创建并返回一个定制的InputSource。清单 10-13 展示了结果类。

清单 10-13。LocalRecipeML

import java.util.HashMap;
import java.util.Map;

import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;

class LocalRecipeML implements EntityResolver
{
   private Map<String, String> mappings = new HashMap<>();
   LocalRecipeML()
   {
      mappings.put("-//FormatData//DTD RecipeML 0.5//EN", "recipeml.dtd");
   }
   @Override
   public InputSource resolveEntity(String publicId, String systemId)
   {
      if (mappings.containsKey(publicId))
      {
         System.out.println("obtaining cached recipeml.dtd");
         systemId = mappings.get(publicId);
         InputSource localSource = new InputSource(systemId);
         return localSource;
      }
      return null;
   }
}

清单 10-13 声明LocalRecipeML。这个类的构造函数在一个 map 中存储 RecipeML DTD 的正式公共标识符和这个 DTD 文档的本地副本的系统标识符——注意 Java 7 的 diamond 操作符(<>)的使用简化了 hashmap 实例化表达式。

images在另一种情况下,您可能会发现地图非常方便。例如,使用映射比在自定义实体解析器中使用一系列 if 语句更容易,该解析器映射 XHTML 的严格、过渡和框架集正式公共标识符,还将其各种实体集映射到这些文档文件的本地副本。

覆盖的resolveEntity()方法使用publicId的参数在映射中定位相应的系统标识符——忽略systemId参数值,因为它从不引用recipeml.dtd的本地副本。找到映射后,会创建并返回一个InputSource对象。如果找不到映射,将返回空引用。

要在SAXDemo中安装这个自定义实体解析器,请在parse()方法调用之前指定xmlr.setEntityResolver(new LocalRecipeML());。重新编译源代码后,执行java SAXDemo gcs.xml,其中gcs.xml存储清单 10-12 的文本。在结果输出中,您应该在调用startEntity()之前观察到消息“obtaining cached recipeml.dtd”。

images 提示SAX API 包括一个org.xml.sax.ext.EntityResolver2接口,它为解析实体提供了改进的支持。如果你喜欢实现EntityResolver2而不是EntityResolver,你必须用一个setFeature()调用替换setEntityResolver()调用来安装实体解析器,这个调用的特性名是use-entity-resolver2(别忘了前缀[xml.org/sax/features/](http://xml.org/sax/features/))。

用 DOM 解析和创建 XML 文档

文档对象模型(DOM) 是一个 API,用于将 XML 文档解析成内存中的节点树,并从节点树创建 XML 文档。在 DOM 解析器创建了文档树之后,应用使用 DOM API 导航并从树的节点中提取信息集项目。

images 注意 DOM 起源于 Netscape Navigator 3 和 Microsoft Internet Explorer 3 web 浏览器的对象模型。这些实现统称为 DOM Level 0。因为每个供应商的 DOM 实现彼此之间只有轻微的兼容性,W3C 随后负责 DOM 的开发以促进标准化,并且到目前为止已经发布了 DOM 级别 1、2 和 3。Java 7 通过其 DOM API 支持所有三个 DOM 级别。

与 SAX 相比,DOM 有两大优势。首先,DOM 允许随机访问文档的信息集项,而 SAX 只允许串行访问。其次,DOM 还允许您创建 XML 文档,而您只能用 SAX 解析文档。但是,SAX 优于 DOM,因为它可以解析任意大小的文档,而由 DOM 解析或创建的文档的大小受到用于存储文档的基于节点的树结构的可用内存量的限制。

本节首先向您介绍 DOM 的树结构。然后,它将带您游览 DOM API 您将学习如何使用这个 API 来解析和创建 XML 文档。

节点树

DOM 将 XML 文档视为由几种节点组成的树。该树只有一个根节点,除了根节点之外的所有节点都有一个父节点。此外,每个节点都有一个子节点列表。如果这个列表是空的,那么这个子节点就是一个叶节点。

images 注意 DOM 允许不属于树结构的节点存在。例如,元素节点的属性节点不被视为元素节点的子节点。此外,可以创建节点,但不能将其插入树中;它们也可以从树中删除。

每个节点都有一个节点名,它是有名称(如元素或属性的前缀名)的节点的完整名称,#*node-type*是未命名的节点,其中 node-typecdata-sectioncommentdocumentdocument-fragmenttext之一。节点也有本地名称(没有前缀的名称)、前缀和名称空间 URIs(尽管这些属性对于某些类型的节点可能是空的,比如注释)。最后,节点有字符串值,恰好是文本节点、评论节点,以及类似的面向文本的节点的内容;属性的规范化值;其他的都是空的。

DOM 将节点分为 12 种类型,其中 7 种类型可以视为 DOM 树的一部分。这里描述了所有这些类型:

  • 属性节点:元素的属性之一。它有一个名称、一个本地名称、一个前缀、一个命名空间 URI 和一个规范化的字符串值。通过解析任何实体引用并将空白序列转换为单个空白字符,该值被规范化。属性节点有子节点,这些子节点是构成其值的文本和任何实体引用节点。属性节点不被视为其关联元素节点的子节点。
  • CDATA 节节点:CDATA 节的内容。它的名字是#cdata-section,它的值是 CDATA 部分的文本。
  • 评论节点:文档评论。它的名字是#comment,它的值是注释文本。注释节点有一个父节点,它是包含注释的节点。
  • 文档片段节点:备选根节点。它的名字是#document-fragment,包含一个元素节点可以包含的任何东西(比如其他元素节点,甚至注释节点)。解析器从不创建这种类型的节点。但是,当应用提取 DOM 树的一部分并将其移动到其他地方时,它可以创建文档片段节点。文档片段节点允许您使用子树。
  • 文档节点:DOM 树的根。它的名字是#document,它总是有一个单一的元素节点子节点,当文档有文档类型声明时,它也会有一个文档类型子节点。此外,它还可以有额外的子节点,这些子节点描述出现在根元素的开始标记之前或之后的注释或处理指令。树中只能有一个文档节点。
  • 单据类型节点:单据类型声明。它的名称是由根元素的文档类型声明指定的名称。此外,它还有一个(可能为空的)公共标识符、一个必需的系统标识符、一个内部 DTD 子集(可能为空)、一个父节点(包含文档类型节点的文档节点)以及 DTD 声明的符号和一般实体的列表。它的值总是设置为 null。
  • 元素节点:单据的元素。它有一个名称、一个本地名称、一个前缀(可能为空)和一个名称空间 URI,当元素不属于任何名称空间时,该名称空间为空。元素节点包含子节点,包括文本节点,甚至注释和处理指令节点。
  • 实体节点:在文档的 DTD 中声明的已解析和未解析的实体。当一个解析器读取一个 DTD 时,它会将一个实体节点映射(由实体名索引)附加到文档类型节点上。实体节点有一个名称和一个系统标识符,如果在 DTD 中出现了一个公共标识符,它也可以有一个公共标识符。最后,当解析器读取实体时,实体节点会得到一个包含实体替换文本的只读子节点列表。
  • 实体引用节点:对 DTD 声明的实体的引用。每个实体引用节点都有一个名称,当解析器没有用实体引用的值替换实体引用时,它会包含在树中。解析器从不包含字符引用的实体引用节点(如&amp;&#0931;),因为它们被各自的字符替换并包含在文本节点中。
  • 符号节点:DTD 声明的符号。读取 DTD 的解析器将符号节点的映射(由符号名索引)附加到文档类型节点。每个符号节点都有一个名称、一个公共标识符或一个系统标识符,无论哪个标识符用于在 DTD 中声明符号。符号节点没有子节点。
  • 处理指令节点:出现在单据中的处理指令。它有一个名称(指令的目标)、一个字符串值(指令的数据)和一个父节点(它的包含节点)。
  • 文本节点:文档内容。它的名字是#text,当必须创建一个中间节点(例如注释)时,它代表元素内容的一部分。通过字符引用在文档中表示的字符如<&被它们所表示的文字字符所替代。当这些节点被写入文档时,这些字符必须被转义。

尽管这些节点类型存储了大量关于 XML 文档的信息,但是也有一些限制(比如不要在根元素之外暴露空白)。相比之下,大多数 DTD 或模式信息,比如元素类型(<!ELEMENT...>)和属性类型<xs:attribute...>,都不能通过 DOM 访问。

DOM Level 3 解决了 DOM 的各种限制。例如,虽然 DOM 没有为 XML 声明提供节点类型,但是 DOM Level 3 使得通过文档节点的属性访问 XML 声明的versionencodingstandalone属性值成为可能。

images 注意非根节点从来不是孤立存在的。例如,元素节点永远不会不属于文档或文档片段。即使当这些节点与主树断开连接时,它们仍然知道它们所属的文档或文档片段。

探索 DOM API

Java 通过javax.xml.parsers包的抽象DocumentBuilderDocumentBuilderFactory类,以及非抽象FactoryConfigurationErrorParserConfigurationException类来实现 DOM。org.w3c.domorg.w3c.dom.bootstraporg.w3c.dom.eventsorg.w3c.dom.ls包提供了各种类型来增强这种实现。

使用 DOM 的第一步是通过调用它的一个newInstance()方法来实例化DocumentBuilderFactory。例如,下面的例子调用了DocumentBuilderFactorystatic DocumentBuilderFactory newInstance()方法:

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();

在幕后,newInstance()遵循一个有序的查找过程来识别要加载的DocumentBuilderFactory实现类。这个过程首先检查javax.xml.parsers.DocumentBuilderFactory系统属性,最后在找不到其他类时选择 Java 平台的默认DocumentBuilderFactory实现类。如果一个实现类不可用(也许由javax.xml.parsers.DocumentBuilderFactory系统属性标识的类不存在)或者不能被实例化,newInstance()抛出一个FactoryConfigurationError类的实例。否则,它实例化该类并返回其实例。

获得一个DocumentBuilderFactory实例后,可以调用各种配置方法来配置工厂。例如,您可以用一个true参数调用DocumentBuilderFactoryvoid setNamespaceAware(boolean awareness)方法,告诉工厂任何返回的解析器(称为 DOM 的文档构建器)必须提供对 XML 名称空间的支持。您还可以使用true作为参数调用void setValidating(boolean validating)来根据文档的 dtd 验证文档,或者调用void setSchema(Schema schema)来根据由schema标识的javax.xml.validation.Schema实例验证文档。

验证 API

JAXP 包含了验证 API 来分离文档解析和验证,这使得应用可以更容易地利用支持其他模式语言的专用验证库(例如,Relax NG—参见[en.wikipedia.org/wiki/RELAX_NG](http://en.wikipedia.org/wiki/RELAX_NG)),并且可以更容易地指定模式的位置。

验证 API 与javax.xml.validation包相关联,它由六个类组成:SchemaSchemaFactorySchemaFactoryLoaderTypeInfoProviderValidatorValidatorHandlerSchema是一个中心类,它代表一种语法的不可变内存表示。

DOM API 通过DocumentBuilderFactoryvoid setSchema(Schema schema)Schema getSchema()方法支持验证 API。类似地,SAX 1.0 支持通过SAXParserFactoryvoid setSchema(Schema schema)Schema getSchema()方法进行验证。SAX 2.0 和 StAX 不支持验证 API。

以下示例演示了 DOM 上下文中的验证 API:

// Parse an XML document into a DOM tree.
DocumentBuilder parser =

   DocumentBuilderFactory.newInstance().newDocumentBuilder();

Document document = parser.parse(new File("instance.xml"));
// Create a SchemaFactory capable of understanding W3C XML Schema (WXS).
SchemaFactory factory =
   SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI);
// Load a WXS schema, represented by a Schema instance.
Source schemaFile = new StreamSource(new File("mySchema.xsd"));
Schema schema = factory.newSchema(schemaFile);
// Create a Validator instance, which is used to validate an XML document.
Validator validator = schema.newValidator();
// Validate the DOM tree.
try
{
   validator.validate(new DOMSource(document));
}
catch (SAXException saxe)
{
   // XML document is invalid!
}

这个例子引用了 XSLT 类型,比如Source。我将在本章后面探讨 XSLT。

配置完工厂后,调用它的DocumentBuilder newDocumentBuilder()方法返回一个支持配置的文档生成器,如下所示:

DocumentBuilder db = dbf.newDocumentBuilder();

如果一个文档构建器不能被返回(也许工厂不能创建一个支持 XML 名称空间的文档构建器),这个方法抛出一个ParserConfigurationException实例。

假设您已经成功地获得了一个文档生成器,接下来会发生什么取决于您是想要解析还是创建一个 XML 文档。

解析 XML 文档

DocumentBuilder提供了几个重载的parse()方法,用于将 XML 文档解析成节点树。这些方法在获取文档的方式上有所不同。例如,Document parse(String uri)解析由基于字符串的 URI 参数标识的文档。

images 注意null作为方法的第一个参数传递时,每个parse()方法抛出java.lang.IllegalArgumentException,当出现输入/输出问题时抛出IOException,当文档无法解析时抛出SAXException。最后一种异常类型意味着DocumentBuilderparse()方法依赖 SAX 来处理实际的解析工作。因为它们更多地参与构建节点树,所以 DOM 解析器通常被称为文档构建器

返回的org.w3c.dom.Document对象通过DocumentType getDoctype()之类的方法提供对已解析文档的访问,这使得通过org.w3c.dom.DocumentType接口可以使用文档类型声明。从概念上讲,Document是文档节点树的根。

images 注意除了DocumentBuilderDocumentBuilderFactory等少数几个类,DOM 是基于接口的,其中DocumentDocumentType就是例子。在幕后,DOM 方法(如parse()方法)返回其类实现这些接口的对象。

Document和所有其他描述不同类型节点的org.w3c.dom接口都是org.w3.dom.Node接口的子接口。因此,它们继承了Node的常量和方法。

Node声明十二个常数,代表各种类型的节点;ATTRIBUTE_NODEELEMENT_NODE就是例子。当您想要识别给定的Node对象所代表的节点类型时,调用Nodeshort getNodeType()方法,并将返回值与这些常量之一进行比较。

images 注意使用getNodeType()和这些常量,而不是使用instanceof和一个类名的基本原理是,DOM(对象模型,而不是 Java DOM API)被设计成与语言无关的,而诸如 AppleScript 之类的语言没有等同的instanceof

Node声明了几个获取和设置公共节点属性的方法。这些方法包括String getNodeName()String getLocalName()String getNamespaceURI()String getPrefix()void setPrefix(String prefix)String getNodeValue()void setNodeValue(String nodeValue),它们允许您获取和(对于某些属性)设置节点的名称(如#text)、本地名称、命名空间 URI、前缀和规范化的字符串值属性。

images 注意各种Node方法(例如setPrefix()getNodeValue())在出错时抛出org.w3c.dom.DOMException类的实例。例如,当prefix参数包含非法字符、节点是只读的或者参数格式不正确时,setPrefix()会抛出这个异常。类似地,当getNodeValue()返回的字符多于实现平台上的DOMString(W3C 类型)变量所能容纳的字符时,getNodeValue()抛出DOMExceptionDOMException声明了一系列常量(如DOMSTRING_SIZE_ERR)对异常原因进行分类。

Node声明了几种导航节点树的方法。它的三种导航方法是boolean hasChildNodes()(当一个节点有子节点时返回 true)Node getFirstChild()(返回节点的第一个子节点)和Node getLastChild()(返回节点的最后一个子节点)。对于有多个子节点的节点,您会发现NodeList getChildNodes()方法非常方便。这个方法返回一个org.w3c.dom.NodeList实例,它的int getLength()方法返回列表中的节点数,它的Node item(int index)方法返回列表中第index个位置的节点(或者当index的值无效时为 null 小于 0 或者大于等于getLength()的值)。

Node声明了通过插入、移除、替换和追加子节点来修改树的四种方法。Node insertBefore(Node newChild, Node refChild)方法在refChild指定的现有节点前插入newChild并返回newChildNode removeChild(Node oldChild)从树中移除oldChild标识的子节点并返回oldChildNode replaceChild(Node newChild, Node oldChild)newChild替换oldChild并返回oldChildNode appendChild(Node newChild)newChild添加到当前节点子节点的末尾并返回newChild

最后,Node声明了几个实用方法,包括Node cloneNode(boolean deep)(创建并返回当前节点的副本,当true传递给deep时递归克隆其子树),以及void normalize()(从给定节点开始向下遍历树,合并所有相邻的文本节点,删除那些空的文本节点)。

images 提示获取一个元素节点的属性,首先调用NodeNamedNodeMap getAttributes()方法。当节点代表一个元素时,这个方法返回一个org.w3c.dom.NamedNodeMap实现;否则,它返回 null。除了通过名字声明访问这些节点的方法(例如Node getNamedItem(String name))之外,NamedNodeMap通过index声明返回所有属性节点的int getLength()Node item(int index)方法。然后,您将通过调用诸如getNodeName()这样的方法来获得Node的名称。

除了继承Node的常量和方法,Document还声明了自己的方法。例如,您可以调用DocumentString getXmlEncoding()boolean getXmlStandalone()String getXmlVersion()方法来分别返回 XML 声明的encodingstandaloneversion属性值。

Document声明了定位一个或多个元素的三种方法:Element getElementById(String elementId)NodeList getElementsByTagName(String tagname)NodeList getElementsByTagNameNS(String namespaceURI,String localName)。第一种方法返回具有与由elementId指定的值匹配的id属性(如在<img id=...>中)的元素,第二种方法返回与指定的tagName匹配的文档元素的节点列表(按文档顺序),第三种方法与第二种方法基本相同,只是在节点列表中只返回与给定的localNamenamespaceURI匹配的元素。将" * "传递给namespaceURI以匹配所有名称空间;传递" * "localName来匹配所有本地名称。

返回的元素节点和列表中的每个元素节点都实现了org.w3c.dom.Element接口。该接口声明了返回树中派生元素的节点列表、与元素相关的属性等的方法。例如,String getAttribute(String name)返回由name标识的属性的值,而Attr getAttributeNode(String name)通过名称返回属性节点。返回的节点是org.w3c.dom.Attr接口的一个实现。

现在,您已经有了足够的信息来开发一个应用,用于解析 XML 文档并从结果 DOM 树中输出元素和属性信息。清单 10-14 展示了这个应用的源代码。

清单 10-14。 DOMDemo(第一版)

`import java.io.IOException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.FactoryConfigurationError;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import org.xml.sax.SAXException; class DOMDemo
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java DOMDemo xmlfile");
         return;
      }
      try
      {
         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
         dbf.setNamespaceAware(true);
         DocumentBuilder db = dbf.newDocumentBuilder();
         Document doc = db.parse(args[0]);
         System.out.println("Version = "+doc.getXmlVersion());
         System.out.println("Encoding = "+doc.getXmlEncoding());
         System.out.println("Standalone = "+doc.getXmlStandalone());
         System.out.println();
         if (doc.hasChildNodes())
         {
            NodeList nl = doc.getChildNodes();
            for (int i = 0; i < nl.getLength(); i++)
            {
               Node node = nl.item(i);
               if (node.getNodeType() == Node.ELEMENT_NODE)
                  dump((Element) node);
            }
         }
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (SAXException saxe)
      {
         System.err.println("SAXE: "+saxe);
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (ParserConfigurationException pce)
      {
         System.err.println("PCE: "+pce);
      }
   }
   static void dump(Element e)
   {
      System.out.println("Element: "+e.getNodeName()+", "+e.getLocalName()+
                         ", "+e.getPrefix()+", "+e.getNamespaceURI());       NamedNodeMap nnm = e.getAttributes();
      if (nnm != null)
         for (int i = 0; i < nnm.getLength(); i++)
         {
            Node node = nnm.item(i);
            Attr attr = e.getAttributeNode(node.getNodeName());
            System.out.printf("  Attribute %s = %s%n", attr.getName(),
                              attr.getValue());
         }
      NodeList nl = e.getChildNodes();
      for (int i = 0; i < nl.getLength(); i++)
      {
         Node node = nl.item(i);
         if (node instanceof Element)
            dump((Element) node);
      }
   }
}`

DOMDemo设计为在命令行运行。在验证已经指定了一个命令行参数(XML 文档的名称)之后,main()创建一个文档构建器工厂,通知工厂它需要一个能够识别名称空间的文档构建器,并让工厂返回这个文档构建器。

继续,main()将文档解析成节点树;输出 XML 声明的版本号、编码和独立属性值。并递归地转储所有元素节点(从根节点开始)及其属性值。

images 注意关于多 catch 块,把它当作一个用多 catch 替换它们的练习。

注意清单的一部分使用了getNodeType(),另一部分使用了instanceofgetNodeType()方法调用是不必要的(它只是为了演示才出现的),因为可以用instanceof来代替。然而,在dump()方法调用中从Node类型到Element类型的转换是必要的。

假设您已经编译了源代码,执行java DOMDemo article.xml来转储清单 10-3 的文章 XML 内容。您应该观察到以下输出:

Version = 1.0
Encoding = null
Standalone = false

Element: article, article, null, null
  Attribute lang = en
  Attribute title = The Rebirth of JavaFX
Element: abstract, abstract, null, null
Element: code-inline, code-inline, null, null
Element: body, body, null, null

每个以Element为前缀的行输出节点名,接着是本地名,接着是名称空间前缀,接着是名称空间 URI。节点和本地名称是相同的,因为没有使用名称空间。出于同样的原因,名称空间前缀和名称空间 URI 都是null

继续执行java DOMDemo recipe.xml,其中recipe.xml包含清单 10-5 中显示的内容。这一次,您会看到以下输出,其中包括名称空间信息:

Version = 1.0
Encoding = null
Standalone = false

Element: h:html, html, h, http://www.w3.org/1999/xhtml
  Attribute xmlns:h = http://www.w3.org/1999/xhtml
  Attribute xmlns:r = http://www.tutortutor.ca/
Element: h:head, head, h, http://www.w3.org/1999/xhtml
Element: h:title, title, h, http://www.w3.org/1999/xhtml
Element: h:body, body, h, http://www.w3.org/1999/xhtml
Element: r:recipe, recipe, r, http://www.tutortutor.ca/
Element: r:title, title, r, http://www.tutortutor.ca/
Element: r:ingredients, ingredients, r, http://www.tutortutor.ca/
Element: h:ul, ul, h, http://www.w3.org/1999/xhtml
Element: h:li, li, h, http://www.w3.org/1999/xhtml
Element: r:ingredient, ingredient, r, http://www.tutortutor.ca/
  Attribute qty = 2
Element: h:li, li, h, http://www.w3.org/1999/xhtml
Element: r:ingredient, ingredient, r, http://www.tutortutor.ca/
Element: h:li, li, h, http://www.w3.org/1999/xhtml
Element: r:ingredient, ingredient, r, http://www.tutortutor.ca/
  Attribute qty = 2
Element: h:p, p, h, http://www.w3.org/1999/xhtml
Element: r:instructions, instructions, r, http://www.tutortutor.ca/
创建 XML 文档

DocumentBuilder声明了创建文档树的抽象Document newDocument()方法。返回的Document对象声明了创建该树的各种create和其他方法。例如,Element createElement(String tagName)创建一个名为tagName的元素,返回一个具有指定名称的新的Element对象,但是它的本地名称、前缀和名称空间 URI 设置为 null。

清单 10-15 展示了DOMDemo应用的另一个版本,它简要地展示了文档树的创建。

清单 10-15。 DOMDemo(第二版)

`import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.FactoryConfigurationError;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.w3c.dom.Element; import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.w3c.dom.Text;

class DOMDemo
{
   public static void main(String[] args)
   {
      try
      {
         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
         DocumentBuilder db = dbf.newDocumentBuilder();
         Document doc = db.newDocument();
         // Create the root element.
         Element root = doc.createElement("movie");
         doc.appendChild(root);
         // Create name child element and add it to the root.
         Element name = doc.createElement("name");
         root.appendChild(name);
         // Add a text element to the name element.
         Text text = doc.createTextNode("Le Fabuleux Destin d'Amélie Poulain");
         name.appendChild(text);
         // Create language child element and add it to the root.
         Element language = doc.createElement("language");
         root.appendChild(language);
         // Add a text element to the language element.
         text = doc.createTextNode("français");
         language.appendChild(text);
         System.out.println("Version = "+doc.getXmlVersion());
         System.out.println("Encoding = "+doc.getXmlEncoding());
         System.out.println("Standalone = "+doc.getXmlStandalone());
         System.out.println();
         NodeList nl = doc.getChildNodes();
         for (int i = 0; i < nl.getLength(); i++)
         {
            Node node = nl.item(i);
            if (node.getNodeType() == Node.ELEMENT_NODE)
               dump((Element) node);
         }
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (ParserConfigurationException pce)
      {
         System.err.println("PCE: "+pce);
      }
   }
   static void dump(Element e)
   {
      System.out.println("Element: "+e.getNodeName()+", "+e.getLocalName()+                          ", "+e.getPrefix()+", "+e.getNamespaceURI());
      NodeList nl = e.getChildNodes();
      for (int i = 0; i < nl.getLength(); i++)
      {
         Node node = nl.item(i);
         if (node instanceof Element)
            dump((Element) node);
         else
         if (node instanceof Text)
            System.out.println("Text: "+((Text) node).getWholeText());
      }
   }
}`

DOMDemo创建清单 10-2 的电影文档。它使用DocumentcreateElement()方法创建根movie元素和movienamelanguage子元素。它还使用DocumentText createTextNode(String data)方法创建附加到namelanguage节点的文本节点。注意对NodeappendChild()方法的调用,将子节点(例如name)追加到父节点(例如movie)。

在创建这个树之后,DOMDemo输出树的元素节点和其他信息。该输出如下所示:

Version = 1.0
Encoding = null
Standalone = false

Element: movie, null, null, null
Element: name, null, null, null
Text: Le Fabuleux Destin d'Amélie Poulain
Element: language, null, null, null
Text: français

输出和预期的差不多,但是有一个问题:XML 声明的encoding属性没有被设置为ISO-8859-1。事实证明,您无法通过 DOM API 完成这项任务。相反,您需要使用 XSLT API 来完成这项任务。在探索 XSLT 的过程中,您将学习如何设置encoding属性,还将学习如何将这个树输出到 XML 文档文件中。

然而,在我们将注意力转向 XSLT 之前,还有一个文档解析和文档创建 API 需要探索(并完成 XPath API 之旅)。

使用 StAX 解析和创建 XML 文档

Streaming API for XML (StAX) 是一个用于从开始到结束顺序解析 XML 文档的 API。它也是一个文档创建 API。StAX 在 Java 6 版本中成为核心 Java API(在 2006 年末)。

STAX 对比 SAX 和 DOM

因为 Java 已经支持用于文档解析的 SAX 和 DOM 以及用于文档创建的 DOM,所以您可能想知道为什么还需要另一个 XML API。以下几点证明了 StAX 在 core Java 中的存在:

  • StAX(像 SAX 一样)可以用来解析任意大小的文档。相比之下,DOM 解析的文档的最大大小受到可用内存的限制,这使得 DOM 不适合内存有限的移动设备。
  • StAX(像 DOM 一样)可以用来创建任意大小的文档。相比之下,DOM 创建的文档的最大大小受到可用内存的限制。SAX 不能用来创建文档。
  • StAX(像 SAX 一样)使得应用几乎可以立即使用 infoset 项。相比之下,DOM 在构建完节点树之后才能使用这些项目。
  • StAX(像 DOM 一样)采用拉模型,应用在准备好接收下一个信息集项目时告诉解析器。这个模型基于迭代器设计模式(参见[sourcemaking.com/design_patterns/iterator](http://sourcemaking.com/design_patterns/iterator)),这使得应用更容易编写和调试。相比之下,SAX 采用推送模型,其中解析器通过事件将信息集项目传递给应用,不管应用是否准备好接收它们。该模型基于观察者设计模式(参见[sourcemaking.com/design_patterns/observer](http://sourcemaking.com/design_patterns/observer)),这导致应用通常更难编写和调试。

总之,StAX 可以解析或创建任意大小的文档,使应用几乎可以立即使用 infoset 项,并使用 pull 模型来管理应用。SAX 和 DOM 都没有提供所有这些优势。

Java 通过存储在javax.xml.streamjavax.xml.stream.eventsjavax.xml.stream.util包中的类型实现 StAX。本节将向您介绍前两个包中的各种类型,同时展示如何使用 StAX 解析和创建 XML 文档。

基于流与基于事件的读取器和写入器

StAX 解析器被称为文档阅读器,StAX 文档创建者被称为文档作者。StAX 将文档阅读器和文档编写器分为基于流的和基于事件的。

基于流的阅读器通过光标(信息集项目指针)从输入流中提取下一个信息集项目。类似地,基于流的编写器将下一个 infoset 项写入光标位置处的输出流。光标一次只能指向一个项目,并且总是向前移动,通常移动一个信息集项目。

在为 Java ME 等内存受限的环境编写代码时,基于流的读取器和写入器是合适的,因为您可以使用它们来创建更小、更高效的代码。它们还为低级别的库提供了更好的性能,在低级别的库中,性能是很重要的。

基于事件的读取器通过获取事件从输入流中提取下一个信息集项目。类似地,基于事件的编写器通过向输出流添加事件,将下一个 infoset 项写入流中。与基于流的读取器和编写器相比,基于事件的读取器和编写器没有游标的概念。

基于事件的读取器和编写器适合创建 XML 处理 管道(转换前一个组件的输入并将转换后的输出传递给序列中的下一个组件的组件序列),适合修改事件序列,等等。

解析 XML 文档

文档阅读器是通过调用在javax.xml.stream.XMLInputFactory类中声明的各种create方法获得的。这些创建方法分为两类:创建基于流的读取器的方法和创建基于事件的读取器的方法。

在获得基于流或基于事件的读取器之前,您需要通过调用一个newFactory()类方法来获得工厂的实例,比如XMLInputFactory newFactory():

XMLInputFactory xmlif = XMLInputFactory.newFactory();

images 注意您也可以调用XMLInputFactory newInstance()类方法,但可能不希望这样做,因为为了保持 API 的一致性,它的同名但参数化的同伴方法已被弃用,而且newInstance()也可能被弃用。

newFactory()方法遵循一个有序的查找过程来定位XMLInputFactory实现类。这个过程首先检查javax.xml.stream.XMLInputFactory系统属性,最后选择 Java 平台的默认XMLInputFactory实现类的名称。如果这个过程找不到类名,或者如果这个类不能被加载(或实例化),这个方法抛出一个javax.xml.stream.FactoryConfigurationError类的实例。

创建工厂后,调用XMLInputFactoryvoid setProperty(String name, Object value)方法,根据需要设置各种特性和属性。例如,您可以执行xmlif.setProperty(XMLInputFactory.IS_VALIDATING, true); ( true通过自动装箱作为Boolean对象传递,在第五章中讨论)来请求一个验证 DTD 的基于流的阅读器。然而,默认的 StAX 工厂实现抛出了IllegalArgumentException,因为它不支持 DTD 验证。类似地,您可以执行xmlif.setProperty(XMLInputFactory.IS_NAMESPACE_AWARE, true);来请求一个支持名称空间感知的基于事件的读取器。

使用基于流的阅读器解析文档

基于流的阅读器是通过调用XMLInputFactorycreateXMLStreamReader()方法之一创建的,比如XMLStreamReader createXMLStreamReader(Reader reader)。当不能创建基于流的读取器时,这些方法抛出javax.xml.stream.XMLStreamException

以下示例创建了一个基于流的读取器,其源是一个名为recipe.xml的文件:

Reader reader = new FileReader("recipe.xml"); XMLStreamReader xmlsr = xmlif.createXMLStreamReader(reader);

底层的XMLStreamReader接口提供了用 StAX 读取 XML 数据的最有效的方法。当有下一个 infoset 项要获取时,该接口的boolean hasNext()方法返回 true 否则,它返回 false。int next()方法将光标向前移动一个信息集项目,并返回一个标识该项目的类型的整数代码。

不是将next()的返回值与一个整数值进行比较,而是将这个值与一个javax.xml.stream.XMLStreamConstants信息集常量进行比较,比如START_ELEMENTDTD——XMLStreamReader扩展了XMLStreamConstants接口。

images 注意你也可以通过调用XMLStreamReaderint getEventType()方法来获取光标所指向的 infoset 项的类型。在这个方法的名称中指定“Event”是很不幸的,因为它混淆了基于流的读取器和基于事件的读取器。

下面的例子使用了hasNext()next()方法来编写一个解析循环,该循环检测每个元素的开始和结束:

while (xmlsr.hasNext())
{
   switch (xmlsr.next())
   {
      case XMLStreamReader.START_ELEMENT: // Do something at element start.
                                          break;
      case XMLStreamReader.END_ELEMENT  : // Do something at element end.
   }
}

XMLStreamReader还声明了提取信息集信息的各种方法。例如,next()返回XMLStreamReader.START_ELEMENTXMLStreamReader.END_ELEMENT时,QName getName()返回光标位置元素的限定名(作为javax.xml.namespace.QName实例)。

images QName将限定名描述为名称空间 URI、本地部分和前缀部分的组合。在实例化这个不可变的类(通过一个像QName(String namespaceURI, String localPart, String prefix)这样的构造函数)之后,您可以通过调用QNameString getNamespaceURI()String getLocalPart()String getPrefix()方法来返回这些组件。

清单 10-16 将源代码呈现给一个StAXDemo应用,该应用通过基于流的阅读器报告 XML 文档的开始和结束元素。

清单 10-16。 StAXDemo(第一版)

`import java.io.FileNotFoundException;
import java.io.FileReader;

import javax.xml.stream.FactoryConfigurationError;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamReader;

class StAXDemo
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java StAXDemo xmlfile");
         return;
      }
      try
      {
         XMLInputFactory xmlif = XMLInputFactory.newFactory();
         XMLStreamReader xmlsr;
         xmlsr = xmlif.createXMLStreamReader(new FileReader(args[0]));
         while (xmlsr.hasNext())
         {
            switch (xmlsr.next())
            {
               case XMLStreamReader.START_ELEMENT:
                  System.out.println("START_ELEMENT");
                  System.out.println("  Qname = "+xmlsr.getName());
                  break;
               case XMLStreamReader.END_ELEMENT:
                  System.out.println("END_ELEMENT");
                  System.out.println("  Qname = "+xmlsr.getName());
            }
         }
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (FileNotFoundException fnfe)
      {
         System.err.println("FNFE: "+fnfe);
      }
      catch (XMLStreamException xmlse)
      {
         System.err.println("XMLSE: "+xmlse);
      }
   } }`

在验证了命令行参数的数量之后,清单 10-16 的main()方法创建一个工厂,使用该工厂创建一个基于流的读取器,该读取器从由单独的命令行参数标识的文件中获取 XML 数据,然后进入一个解析循环。每当next()返回XMLStreamReader.START_ELEMENTXMLStreamReader.END_ELEMENT时,就会调用XMLStreamReadergetName()方法来返回元素的限定名。

例如,当您对清单 10-2 的电影文档文件(movie.xml)执行StAXDemo时,该应用会生成以下输出:

START_ELEMENT
  Qname = movie
START_ELEMENT
  Qname = name
END_ELEMENT
  Qname = name
START_ELEMENT
  Qname = language
END_ELEMENT
  Qname = language
END_ELEMENT
  Qname = movie

images 注意 : XMLStreamReader声明了一个void close()方法,当您的应用被设计为长时间运行时,您将希望调用该方法来释放与这个基于流的读取器相关联的任何资源。调用此方法不会关闭基础输入源。

用基于事件的阅读器解析文档

基于事件的阅读器是通过调用XMLInputFactorycreateXMLEventReader()方法之一创建的,比如XMLEventReader createXMLEventReader(Reader reader)。当无法创建基于事件的读取器时,这些方法抛出XMLStreamException

以下示例创建了一个基于事件的读取器,其源是一个名为recipe.xml的文件:

Reader reader = new FileReader("recipe.xml");
XMLEventReader xmler = xmlif.createXMLEventReader(reader);

高级的XMLEventReader接口提供了一种效率稍低但更面向对象的方式来用 StAX 读取 XML 数据。当有下一个事件要获取时,该接口的boolean hasNext()方法返回 true 否则,它返回 false。XMLEvent nextEvent()方法将下一个事件作为一个对象返回,该对象的类实现了javax.xml.stream.events.XMLEvent接口的子接口。

images 注意 XMLEvent是处理标记事件的基础接口。它声明适用于所有子接口的方法;例如,Location getLocation()(返回一个javax.xml.stream.Location对象,其int getCharacterOffset()和其他方法返回事件的位置信息)和int getEventType()(将事件类型作为XMLStreamConstants infoset 常量返回,如START_ELEMENTPROCESSING_INSTRUCTIONXMLEvent扩展XMLStreamConstants)。XMLEvent由其他javax.xml.stream.events接口子类型化,这些接口根据返回 infoset 项目特定信息的方法(例如AttributeQName getName()String getValue()方法)描述不同种类的事件(例如Attribute)。

下面的例子使用了hasNext()nextEvent()方法来编写一个解析循环,该循环检测元素的开始和结束:

while (xmler.hasNext())
{
   switch (xmler.nextEvent().getEventType())
   {
      case XMLEvent.START_ELEMENT: // Do something at element start.
                                   break;
      case XMLEvent.END_ELEMENT  : // Do something at element end.
   }
}

清单 10-17 将源代码呈现给一个StAXDemo应用,该应用通过基于事件的阅读器报告 XML 文档的开始和结束元素。

清单 10-17。 StAXDemo(第二版)

`import java.io.FileNotFoundException;
import java.io.FileReader;

import javax.xml.stream.FactoryConfigurationError;
import javax.xml.stream.XMLEventReader;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamException;

import javax.xml.stream.events.EndElement;
import javax.xml.stream.events.StartElement;
import javax.xml.stream.events.XMLEvent;

class StAXDemo
{
   public static void main(String[] args)
   {
      if (args.length != 1)
      {
         System.err.println("usage: java StAXDemo xmlfile");          return;
      }
      try
      {
         XMLInputFactory xmlif = XMLInputFactory.newFactory();
         XMLEventReader xmler;
         xmler = xmlif.createXMLEventReader(new FileReader(args[0]));
         while (xmler.hasNext())
         {
            XMLEvent xmle = xmler.nextEvent();
            switch (xmle.getEventType())
            {
               case XMLEvent.START_ELEMENT:
                  System.out.println("START_ELEMENT");
                  System.out.println("  Qname = "+
                                     ((StartElement) xmle).getName());
                  break;
               case XMLEvent.END_ELEMENT:
                  System.out.println("END_ELEMENT");
                  System.out.println("  Qname = "+
                                     ((EndElement) xmle).getName());
            }
         }
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (FileNotFoundException fnfe)
      {
         System.err.println("FNFE: "+fnfe);
      }
      catch (XMLStreamException xmlse)
      {
         System.err.println("XMLSE: "+xmlse);
      }
   }
}`

在验证了命令行参数的数量之后,清单 10-17 的main()方法创建一个工厂,使用该工厂创建一个基于事件的读取器,该读取器从由单独的命令行参数标识的文件中获取 XML 数据,然后进入一个解析循环。每当nextEvent()返回XMLEvent.START_ELEMENTXMLEvent.END_ELEMENT时,就会调用StartElementEndElementgetName()方法来返回元素的限定名。

例如,当您对清单 10-3 的文章文档文件(article.xml)执行StAXDemo时,该应用生成以下输出:

START_ELEMENT   Qname = article START_ELEMENT   Qname = abstract START_ELEMENT   Qname = code-inline END_ELEMENT   Qname = code-inline END_ELEMENT   Qname = abstract START_ELEMENT   Qname = body END_ELEMENT   Qname = body END_ELEMENT   Qname = article

images 注意你也可以通过调用XMLInputFactorycreateFilteredReader()方法之一,比如XMLEventReader createFilteredReader(XMLEventReader reader, EventFilter filter),创建一个过滤的基于事件的阅读器来接受或拒绝各种事件。javax.stream.xml.EventFilter接口声明了一个boolean accept(XMLEvent event)方法,当指定的事件是事件序列的一部分时,该方法返回 true 否则,它返回 false。

创建 XML 文档

文档编写器是通过调用在javax.xml.stream.XMLOutputFactory类中声明的各种create方法获得的。这些创建方法分为两类:创建基于流的编写器的方法和创建基于事件的编写器的方法。

在获得基于流或基于事件的编写器之前,您需要通过调用一个newFactory()类方法来获得工厂的实例,比如XMLOutputFactory newFactory():

XMLOutputFactory xmlof = XMLOutputFactory.newFactory();

images 注意您也可以调用XMLOutputFactory newInstance()类方法,但可能不希望这样做,因为为了保持 API 的一致性,它的同名但参数化的同伴方法已被弃用,而且newInstance()也可能被弃用。

newFactory()方法遵循一个有序的查找过程来定位XMLOutputFactory实现类。这个过程首先检查javax.xml.stream.XMLOutputFactory系统属性,最后选择 Java 平台的默认XMLOutputFactory实现类的名称。如果这个过程找不到类名,或者如果这个类不能被加载(或实例化),这个方法抛出一个FactoryConfigurationError类的实例。

创建工厂后,调用XMLOutputFactoryvoid setProperty(String name, Object value)方法,根据需要设置各种特性和属性。目前所有作家支持的唯一财产是XMLOutputFactory.IS_REPAIRING_NAMESPACES。启用时(通过将trueBoolean对象,如Boolean.TRUE传递给value),文档编写器负责所有名称空间绑定和声明,只需应用提供最少的帮助。就名称空间而言,输出总是格式良好的。但是,启用该属性会给编写 XML 的工作增加一些开销。

使用基于流的编写器创建文档

基于流的编写器是通过调用XMLOutputFactorycreateXMLStreamWriter()方法之一创建的,比如XMLStreamWriter createXMLStreamWriter(Writer writer)。当无法创建基于流的编写器时,这些方法抛出XMLStreamException

以下示例创建了一个基于流的编写器,其目标是一个名为recipe.xml的文件:

Writer writer = new FileWriter("recipe.xml");
XMLStreamWriter xmlsw = xmlof.createXMLStreamWriter(writer);

底层的XMLStreamWriter接口声明了几个将 infoset 项写到目的地的方法。下面的列表描述了其中的一些方法:

  • 关闭这个基于流的编写器并释放所有相关的资源。基础编写器未关闭。
  • 将任何缓存的数据写入底层编写器。
  • void setPrefix(String prefix, String uri)标识了uri值绑定到的名称空间prefix。这个prefixwriteStartElement()writeAttribute()writeEmptyElement()方法的变体使用,这些方法接受名称空间参数而不接受前缀。同样,它保持有效,直到对应于最后一次writeStartElement()调用的writeEndElement()调用。此方法不创建任何输出。
  • void writeAttribute(String localName, String value)将由localName标识并具有指定的value的属性写入底层编写器。不包括名称空间前缀。该方法转义&<>"
  • void writeCharacters(String text)text的字符写入底层编写器。这种方法逃不过&<,>
  • 关闭所有开始标签,并将相应的结束标签写入底层编写器。
  • void endElement()将结束标记写入底层编写器,依靠基于流的编写器的内部状态来确定标记的前缀和本地名称。
  • 将命名空间写入底层编写器。必须调用此方法以确保写入由setPrefix()指定并在此方法调用中复制的名称空间;否则,从名称空间的角度来看,生成的文档将不是格式良好的。
  • 将 XML 声明写入底层编写器。
  • void writeStartElement(String namespaceURI, String localName)用传递给namespaceURIlocalName的参数写一个开始标签给底层编写器。

清单 10-18 将源代码呈现给一个StAXDemo应用,该应用通过一个基于流的编写器创建一个包含许多清单 10-5 的信息集项目的recipe.xml文件。

清单 10-18。 StAXDemo(第三版)

`import java.io.FileWriter;
import java.io.IOException;

import javax.xml.stream.FactoryConfigurationError;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;
import javax.xml.stream.XMLStreamWriter;

class StAXDemo
{
   public static void main(String[] args)
   {
      try
      {
         XMLOutputFactory xmlof = XMLOutputFactory.newFactory();
         XMLStreamWriter xmlsw;
         xmlsw = xmlof.createXMLStreamWriter(new FileWriter("recipe.xml"));
         xmlsw.writeStartDocument();
         xmlsw.setPrefix("h", "http://www.w3.org/1999/xhtml");
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "html");
         xmlsw.writeNamespace("h", "http://www.w3.org/1999/xhtml");
         xmlsw.writeNamespace("r", "http://www.tutortutor.ca/");
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "head");
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "title");
         xmlsw.writeCharacters("Recipe");
         xmlsw.writeEndElement();
         xmlsw.writeEndElement();
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "body");
         xmlsw.setPrefix("r", "http://www.tutortutor.ca/");
         xmlsw.writeStartElement("http://www.tutortutor.ca/", "recipe");
         xmlsw.writeStartElement("http://www.tutortutor.ca/", "title");
         xmlsw.writeCharacters("Grilled Cheese Sandwich");
         xmlsw.writeEndElement();
         xmlsw.writeStartElement("http://www.tutortutor.ca/", "ingredients");
         xmlsw.setPrefix("h", "http://www.w3.org/1999/xhtml");
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "ul");
         xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "li");
         xmlsw.setPrefix("r", "http://www.tutortutor.ca/");
         xmlsw.writeStartElement("http://www.tutortutor.ca/", "ingredient");
         xmlsw.writeAttribute("qty", "2");
         xmlsw.writeCharacters("bread slice");
         xmlsw.writeEndElement();
         xmlsw.setPrefix("h", "http://www.w3.org/1999/xhtml");
         xmlsw.writeEndElement();
         xmlsw.writeEndElement();
         xmlsw.setPrefix("r", "http://www.tutortutor.ca/");
         xmlsw.writeEndElement();          xmlsw.writeEndDocument();
         xmlsw.flush();
         xmlsw.close();
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (XMLStreamException xmlse)
      {
         System.err.println("XMLSE: "+xmlse);
      }
   }
}`

尽管清单 10-18 很容易理解,但是您可能会对在setPrefix()writeStartElement()方法调用中重复的名称空间 URIs 感到困惑。例如,你可能想知道xmlsw.setPrefix("h", "http://www.w3.org/1999/xhtml");中的重复 URIs 和它的xmlsw.writeStartElement("http://www.w3.org/1999/xhtml", "html");继任者。

setPrefix()方法调用创建名称空间前缀(值)和 URI(键)之间的映射,而不生成任何输出。writeStartElement()方法调用指定了 URI 键,该方法使用该键来访问前缀值,然后在将该标签写入底层编写器之前,将该前缀值(用冒号字符)添加到html开始标签的名称之前。

使用基于事件的编写器创建文档

基于事件的编写器是通过调用XMLOutputFactorycreateXMLEventWriter()方法之一创建的,比如XMLEventWriter createXMLEventWriter(Writer writer)。当无法创建基于事件的编写器时,这些方法抛出XMLStreamException

以下示例创建一个基于事件的编写器,其目标是一个名为recipe.xml的文件:

Writer writer = new FileWriter("recipe.xml");
XMLEventWriter xmlew = xmlof.createXMLEventWriter(writer);

高级的XMLEventWriter接口声明了void add(XMLEvent event)方法,用于将描述信息集项目的事件添加到底层编写器实现的输出流中。传递给event的每个参数都是一个类的实例,该类实现了XMLEvent的子接口(比如AttributeStartElement)。

images 提示 XMLEventWriter还声明了一个void add(XMLEventReader reader)方法,您可以用它将一个XMLEventReader实例链接到一个XMLEventWriter实例。

为了省去你实现这些接口的麻烦,StAX 提供了javax.xml.stream.EventFactory。这个工具类声明了创建XMLEvent子接口实现的各种工厂方法。例如,Comment createComment(String text)返回一个对象,该对象的类实现了XMLEventjavax.xml.stream.events.Comment子接口。

因为这些工厂方法被声明为abstract,所以您必须首先获得一个EventFactory类的实例。您可以通过调用EventFactoryXMLEventFactory newFactory()类方法轻松完成这项任务,如下所示:

XMLEventFactory xmlef = XMLEventFactory.newFactory();

然后,您可以获得一个XMLEvent子接口实现,如下所示:

XMLEvent comment = xmlef.createComment("ToDo");

清单 10-19 将源代码呈现给一个StAXDemo应用,该应用通过一个基于事件的编写器创建一个包含许多清单 10-5 的信息集项目的recipe.xml文件。

清单 10-19。 StAXDemo(第四版)

`import java.io.FileWriter;
import java.io.IOException;

import java.util.Iterator;

import javax.xml.stream.FactoryConfigurationError;
import javax.xml.stream.XMLEventFactory;
import javax.xml.stream.XMLEventWriter;
import javax.xml.stream.XMLOutputFactory;
import javax.xml.stream.XMLStreamException;

import javax.xml.stream.events.Attribute;
import javax.xml.stream.events.Namespace;
import javax.xml.stream.events.XMLEvent;

class StAXDemo
{
   public static void main(String[] args)
   {
      try
      {
         XMLOutputFactory xmlof = XMLOutputFactory.newFactory();
         XMLEventWriter xmlew;
         xmlew = xmlof.createXMLEventWriter(new FileWriter("recipe.xml"));
         final XMLEventFactory xmlef = XMLEventFactory.newFactory();
         XMLEvent event = xmlef.createStartDocument();
         xmlew.add(event);
         Iterator nsIter;
         nsIter = new Iterator()
         {
            int index = 0;
            Namespace[] ns;
            {
               ns = new Namespace[2]; ns[0] = xmlef.createNamespace("h",
                                             "http://www.w3.org/1999/xhtml");
               ns[1] = xmlef.createNamespace("r",
                                             "http://www.tutortutor.ca/");
            }
            public boolean hasNext()
            {
               return index != 2;
            }
            public Namespace next()
            {
               return ns[index++];
            }
            public void remove()
            {
               throw new UnsupportedOperationException();
            }
         };
         event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "html", null, nsIter);
         xmlew.add(event);
         event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "head");
         xmlew.add(event);
         event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "title");
         xmlew.add(event);
         event = xmlef.createCharacters("Recipe");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",
                                        "title");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",
                                        "head");
         xmlew.add(event);
         event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "body");
         xmlew.add(event);
         event = xmlef.createStartElement("r", "http://www.tutortutor.ca/",
                                          "recipe");
         xmlew.add(event);
         event = xmlef.createStartElement("r", "http://www.tutortutor.ca/",
                                          "title");
         xmlew.add(event);
         event = xmlef.createCharacters("Grilled Cheese Sandwich");
         xmlew.add(event);
         event = xmlef.createEndElement("r", "http://www.tutortutor.ca/",
                                        "title");
         xmlew.add(event);
         event = xmlef.createStartElement("r", "http://www.tutortutor.ca/",
                                          "ingredients");
         xmlew.add(event); event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "ul");
         xmlew.add(event);
         event = xmlef.createStartElement("h", "http://www.w3.org/1999/xhtml",
                                          "li");
         xmlew.add(event);
         Iterator attrIter;
         attrIter = new Iterator()
         {
            int index = 0;
            Attribute[] attrs;
            {
               attrs = new Attribute[1];
               attrs[0] = xmlef.createAttribute("qty", "2");
            }
            public boolean hasNext()
            {
               return index != 1;
            }
            public Attribute next()
            {
               return attrs[index++];
            }
            public void remove()
            {
               throw new UnsupportedOperationException();
            }
         };
         event = xmlef.createStartElement("r", "http://www.tutortutor.ca/",
                                          "ingredient", attrIter, null);
         xmlew.add(event);
         event = xmlef.createCharacters("bread slice");
         xmlew.add(event);
         event = xmlef.createEndElement("r", "http://www.tutortutor.ca/",
                                        "ingredient");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",
                                        "li");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",
                                        "ul");
         xmlew.add(event);
         event = xmlef.createEndElement("r", "http://www.tutortutor.ca/",
                                        "ingredients");
         xmlew.add(event);
         event = xmlef.createEndElement("r", "http://www.tutortutor.ca/",
                                        "recipe");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",
                                        "body");
         xmlew.add(event);
         event = xmlef.createEndElement("h", "http://www.w3.org/1999/xhtml",                                         "html");
         xmlew.add(event);
         xmlew.flush();
         xmlew.close();
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (XMLStreamException xmlse)
      {
         System.err.println("XMLSE: "+xmlse);
      }
   }
}`

清单 10-19 应该很容易理解;它是基于事件的清单 10-18 中的的等价物。注意,这个清单包括从实现这个接口的匿名类创建的java.util.Iterator实例。创建这些迭代器是为了将名称空间或属性传递给XMLEventFactoryStartElement createStartElement(String prefix, String namespaceUri, String localName, Iterator attributes, Iterator namespaces)方法。(当迭代器不适用时,可以将null传递给这个参数;例如,当开始标签没有属性时。)

用 XPath 选择 XML 文档节点

XPath 是一种非 XML 声明性查询语言(由 W3C 定义),用于选择 XML 文档的信息集项目作为一个或多个节点。例如,您可以使用 XPath 定位清单 10-1 的第三个ingredient元素并返回这个元素节点。

XPath 通常用于简化对 DOM 树节点的访问,也用于 XSLT 的上下文中(将在下一节讨论),通常用于选择那些要复制到输出文档的输入文档元素(通过 XPath 表达式)。Java 7 通过 JAXP 的 XPath API 支持 XPath 1.0,该 API 被分配了包javax.xml.xpath

本节首先让您熟悉 XPath 1.0 语言。然后演示 XPath 如何简化 DOM 树节点的选择。最后,本节向您介绍三个高级 XPath 主题。

XPath 语言入门

XPath 将 XML 文档视为从根节点开始的节点树。XPath 识别七种节点:元素、属性、文本、名称空间、处理指令、注释和文档。XPath 不识别 CDATA 节、实体引用或文档类型声明。

images 注意树的根节点(DOM Document实例)与文档的根元素不同。根节点包含整个文档,包括根元素、出现在根元素开始标记之前的任何注释或处理指令,以及出现在根元素结束标记之后的任何注释或处理指令。

XPath 为选择节点提供了位置路径表达式。一个位置路径表达式通过一系列步骤定位节点,从上下文节点(根节点或当前节点的某个其他文档节点)开始。返回的节点集可能为空,也可能包含一个或多个节点。

最简单的位置路径表达式选择文档的根节点,由一个正斜杠字符(/)组成。下一个最简单的位置路径表达式是元素的名称,它选择具有该名称的上下文节点的所有子元素。例如,ingredient指的是清单 10-1 的 XML 文档中上下文节点的所有ingredient子元素。当ingredients是上下文节点时,这个 XPath 表达式返回一组三个ingredient节点。但是,如果recipeinstructions恰好是上下文节点,ingredient不会返回任何节点(ingredient只是ingredients的子节点)。当表达式以/开头时,该表达式表示从根节点开始的绝对路径。例如,/movie选择清单 10-2 的 XML 文档中根节点的所有movie子元素。

属性也由位置路径表达式处理。要选择元素的属性,请指定@,后跟属性的名称。例如,@qty选择上下文节点的qty属性节点。

在大多数情况下,您将使用根节点、元素节点和属性节点。但是,您可能还需要使用名称空间节点、文本节点、处理指令节点和注释节点。与通常由 XSLT 处理的名称空间节点不同,您更可能需要处理注释、文本和处理指令。XPath 提供了用于选择注释、文本和处理指令节点的comment()text()processing-instruction()函数。

comment()text()函数不需要参数,因为注释和文本节点没有名称。每个注释都是一个单独的注释节点,每个文本节点指定没有被标签打断的最长文本串。可以用一个标识处理指令目标的参数调用processing-instruction()函数。如果不带参数调用,则选择上下文节点的所有处理指令子节点。

XPath 为选择未知节点提供了三个通配符。*通配符匹配任何元素节点,不管节点的类型如何。它不匹配属性、文本节点、注释或处理指令节点。当您在*前面放置名称空间前缀时,只有属于该名称空间的元素才匹配。node()通配符是一个匹配所有节点的函数。最后,@*通配符匹配所有属性节点。

images 注意 XPath 允许您使用竖线(|)进行多重选择。例如,author/*|publisher/*选择author的子节点和publisher的子节点,*|@*匹配所有元素和属性,但不匹配文本、注释或处理指令节点。

XPath 允许您通过使用/字符来分隔步骤,从而将步骤组合成复合路径。对于以/开头的路径,第一个路径步长是相对于根节点的;否则,第一个路径步骤相对于另一个上下文节点。例如,/movie/name从根节点开始,选择根节点的所有movie元素子节点,选择所选movie节点的所有name子节点。如果您想返回所选的name元素的所有文本节点,您可以指定/movie/name/text()

复合路径可以包括//来从上下文节点的所有后代中选择节点(包括上下文节点)。当放置在表达式的开始时,//从整个树中选择节点。例如,//ingredient选择树中的所有ingredient节点。

对于允许您用单个句点(.)标识当前目录,用双句点(..)标识其父目录的文件系统,您可以指定单个句点来表示当前节点,并用双句点来表示当前节点的父节点。(通常在 XSLT 中使用一个句点来表示您想要访问当前匹配元素的值。)

可能有必要缩小 XPath 表达式返回的节点的选择范围。例如,/recipe/ingredients/ingredient返回所有的ingredient节点,但是也许您只想返回第一个ingredient节点。您可以通过在位置路径中包含谓词来缩小选择范围。

一个谓词是一个方括号分隔的布尔表达式,针对每个选择的节点进行测试。如果表达式的计算结果为 true,则该节点包含在 XPath 表达式返回的节点集中;否则,该节点不包含在集合中。例如,/recipe/ingredients/ingredient[1]选择第一个ingredient元素,它是ingredients元素的子元素。

谓词可以包括预定义的函数(例如,last()position())、操作符(例如,-<=)以及其他项目。例如,/recipe/ingredients/ingredient[last()]选择最后一个ingredient元素,它是ingredients元素的子元素,/recipe/ingredients/ingredient[last()-1]选择倒数第二个ingredient元素,它是ingredients元素的子元素,/recipe/ingredients/ingredient[position()<3]选择前两个ingredient元素,它们是ingredients元素的子元素,//ingredient[@qty]选择所有具有qty属性的ingredient元素(无论它们位于何处),而//ingredient[@qty='1']//ingredient[@qty="1"]选择所有ingredient元素(无论它们位于何处

尽管谓词应该是布尔表达式,但谓词可能不会计算为布尔值。例如,它可以计算数字或字符串—XPath 支持布尔值、数字(IEEE 754 双精度浮点值)、字符串表达式类型以及位置路径表达式的节点集类型。如果谓词的计算结果是一个数字,当它等于上下文节点的位置时,XPath 会将该数字转换为 true 否则,XPath 会将该数字转换为 false。如果谓词的计算结果是一个字符串,当字符串不为空时,XPath 会将该字符串转换为 true 否则,XPath 会将该字符串转换为 false。最后,如果谓词的计算结果是一个节点集,当节点集非空时,XPath 会将该节点集转换为 true 否则,XPath 会将该节点集转换为 false。

images 注意前面给出的位置路径表达式示例演示了 XPath 的简化语法。然而,XPath 还支持一种完整的语法,这种语法更好地描述了正在发生的事情,并且基于一个轴说明符,它指示 XML 文档的树表示中的导航方向。例如,/movie/name使用缩写语法选择根节点的所有movie子元素,然后选择movie元素的所有name子元素,/child::movie/child::name使用扩展语法完成相同的任务。查看维基百科的“XPath 1.0”条目([en.wikipedia.org/wiki/XPath_1.0](http://en.wikipedia.org/wiki/XPath_1.0))了解更多信息。

位置路径表达式(返回节点集)是 XPath 表达式的一种。XPath 还支持评估为布尔值(例如谓词)、数字或字符串类型的通用表达式;比如position()=26.8"Hello"。XSLT 中经常使用通用表达式。

XPath 布尔值可以通过关系运算符<<=>>==!=进行比较。布尔表达式可以通过使用操作符andor来组合。XPath 预定义了boolean()函数将其参数转换为字符串,not()函数在其布尔参数为 false 时返回 true,反之亦然,true()函数返回 true,false()函数返回 false,lang()函数返回 true 或 false,这取决于上下文节点的语言(由xml:lang属性指定)是否与参数字符串指定的语言相同,或者是否是该语言的子语言。

XPath 提供了用于处理数字的+-*divmod(余数)运算符——正斜杠不能用于除法,因为该字符用于分隔位置步骤。所有五个操作符的行为都像 Java 语言中的操作符一样。XPath 还预定义了number()函数,用于将其参数转换为数字,sum()用于返回其 nodeset 参数中节点表示的数值之和,floor()用于返回不大于其 number 参数的最大(最接近正无穷大)整数,ceiling()用于返回不小于其 number 参数的最小(最接近负无穷大)整数,round()用于返回最接近该参数的整数。当有两个这样的数字时,返回最接近正无穷大的一个。

XPath 字符串是用单引号或双引号括起来的有序字符序列。字符串文字不能包含同样用于分隔字符串的引号。例如,包含单引号的字符串不能用单引号分隔。XPath 提供了用于比较字符串的=!=操作符。XPath 还预定义了string()函数将其参数转换为字符串,concat()函数返回其字符串参数的串联,starts-with()函数在第一个参数字符串以第二个参数字符串开始时返回 true(否则返回 false),而contains()函数在第一个参数字符串包含第二个参数字符串时返回 true(否则返回 false), substring-before()返回第一个参数字符串中第二个参数字符串第一次出现之前的第一个参数字符串的子字符串,或者当第一个参数字符串不包含第二个参数字符串时返回空字符串,substring-after()返回第一个参数字符串中第二个参数字符串第一次出现之后的第一个参数字符串的子字符串, 或者当第一个参数字符串不包含第二个参数字符串时为空字符串,substring()返回第一个(字符串)参数的子字符串,从第二个(数字)参数中指定的位置开始,长度在第三个(数字)参数中指定,string-length()返回其字符串参数中的字符数(或者在没有参数的情况下转换为字符串时上下文节点的长度), normalize-space()返回带有空格的参数字符串,通过去除前导和尾随空格并将空格字符序列替换为单个空格(或者在没有参数的情况下转换为字符串时对上下文节点执行相同的操作),以及translate()返回第一个参数字符串,其中第二个参数字符串中出现的字符被第三个参数字符串中相应位置的字符替换。

最后,XPath 预定义了几个用于节点集的函数:last()返回一个标识最后一个节点的数字,position()返回一个标识节点位置的数字,count()返回其节点集参数中的节点数,id()通过元素的惟一 id 选择元素并返回这些元素的节点集,local-name()返回其节点集参数中第一个节点的限定名的本地部分,namespace-uri()返回其节点集参数中第一个节点的限定名的名称空间部分,name()返回其节点集参数中第一个节点的限定名

XPath 和 DOM

假设你需要有人在你家买一袋糖。你可以告诉这个人“请给我买些糖。”或者,你可以这样说:“请打开前门。走到人行道上。向左转。沿着人行道走三个街区。向右转。沿着人行道走一个街区。进入商店。去 7 号通道。沿着过道走两米。拿起一袋糖。走向收银台。付钱买糖。折回你的家。”大多数人都希望接受较短的指导,如果你养成了提供较长指导的习惯,他们可能会让你去某个机构。

遍历节点的 DOM 树类似于提供更长的指令序列。相比之下,XPath 让您通过简洁的指令遍历这棵树。要亲自了解这种差异,请考虑这样一个场景:您有一个基于 XML 的 contacts 文档,其中列出了您的各种专业联系人。清单 10-20 给出了这样一个文档的简单例子。

清单 10-20。基于 XML 的联系人数据库

<?xml version="1.0"?>
<contacts>
   <contact>
      <name>John Doe</name>
      <city>Chicago</city>
      <city>Denver</city>
   </contact>
   <contact>
      <name>Jane Doe</name>
      <city>New York</city>
   </contact>
   <contact>
      <name>Sandra Smith</name>
      <city>Denver</city>
      <city>Miami</city>
   </contact>
   <contact>
      <name>Bob Jones</name>
      <city>Chicago</city>
   </contact>
</contacts>

清单 10-20 展示了一个简单的 XML 语法,它由一个包含一系列contact元素的contacts根元素组成。每个contact元素包含一个name元素和一个或多个city元素(您的联系人经常出差,在每个城市花费大量时间)。(为了保持示例简单,我没有提供 DTD 或模式。)

假设您想查找并输出每年至少有一部分时间住在芝加哥的所有联系人的姓名。清单 10-21 将源代码呈现给一个用 DOM API 完成这项任务的DOMSearch应用。

清单 10-21。使用 DOM API 定位芝加哥联系人

`import java.io.IOException;

import java.util.ArrayList;
import java.util.List; import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.FactoryConfigurationError;
import javax.xml.parsers.ParserConfigurationException;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

import org.xml.sax.SAXException;

class DOMSearch
{
   public static void main(String[] args)
   {
      try
      {
         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
         DocumentBuilder db = dbf.newDocumentBuilder();
         Document doc = db.parse("contacts.xml");
         List contactNames = new ArrayList();
         NodeList contacts = doc.getElementsByTagName("contact");
         for (int i = 0; i < contacts.getLength(); i++)
         {
            Element contact = (Element) contacts.item(i);
            NodeList cities = contact.getElementsByTagName("city");
            boolean chicago = false;
            for (int j = 0; j < cities.getLength(); j++)
            {
               Element city = (Element) cities.item(j);
               NodeList children = city.getChildNodes();
               StringBuilder sb = new StringBuilder();
               for (int k = 0; k < children.getLength(); k++)
               {
                  Node child = children.item(k);
                  if (child.getNodeType() == Node.TEXT_NODE)
                     sb.append(child.getNodeValue());
               }
               if (sb.toString().equals("Chicago"))
               {
                  chicago = true;
                  break;
               }
            }
            if (chicago)
            {
               NodeList names = contact.getElementsByTagName("name");
               contactNames.add(names.item(0).getFirstChild().getNodeValue());
            }
         }          for (String contactName: contactNames)
            System.out.println(contactName);
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (SAXException saxe)
      {
         System.err.println("SAXE: "+saxe);
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);
      }
      catch (ParserConfigurationException pce)
      {
         System.err.println("PCE: "+pce);
      }
   }
}`

在解析contacts.xml并构建 DOM 树之后,main()使用DocumentgetElementsByTagName()方法返回一个contact元素节点的NodeList。对于这个列表的每个成员,main()提取contact元素节点,并使用这个节点和getElementsByTagName()返回一个contact元素节点的city元素节点的NodeList

对于cities列表的每个成员,main()提取city元素节点,并使用这个节点和getElementsByTagName()返回city元素节点的子节点的NodeList——在这个例子中只有一个子文本节点,但是注释或处理指令的出现会增加子节点的数量。例如,<city>Chicago<!--The windy city--></city>将子节点的数量增加到 2。

如果子节点类型指示它是文本节点,则子节点的值(通过getNodeValue()获得)存储在字符串生成器中——在本例中,字符串生成器中只存储一个子节点。如果构建器的内容表明已经找到了Chicago,则chicago标志被设置为真,并且执行离开cities循环。

如果在cities循环退出时设置了chicago标志,则调用当前contact元素节点的getElementsByTagName()方法来返回contact元素节点的name元素节点的NodeList(其中应该只有一个,我可以通过 DTD 或 schema 来实施)。现在很简单,从这个列表中提取第一个项目,调用这个项目上的getFirstChild()返回文本节点(我假设只有文本出现在<name></name>之间),调用文本节点上的getNodeValue()获得它的值,然后将它添加到contactNames列表中。

编译完源代码后,运行应用。您应该观察到以下输出:

John Doe
Bob Jones

遍历 DOM 的节点树在最好的情况下是一项乏味的工作,在最坏的情况下容易出错。幸运的是,XPath 可以大大简化这种情况。

在编写清单 10-21 的 XPath 等价物之前,定义一个位置路径表达式是有帮助的。对于本例,该表达式是//contact[city="Chicago"]/name/text(),它使用一个谓词选择包含一个Chicago city节点的所有contact节点,然后从这些contact节点中选择所有子name节点,最后从这些name节点中选择所有子文本节点。

清单 10-22 给出了一个XPathSearch应用的源代码,该应用使用这个 XPath 表达式和 XPath API 来定位 Chicago 联系人。

清单 10-22。使用 XPath API 定位芝加哥联系人

`import java.io.IOException;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.FactoryConfigurationError;
import javax.xml.parsers.ParserConfigurationException;

import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathException;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.Document;
import org.w3c.dom.NodeList;

import org.xml.sax.SAXException;

class XPathSearch
{
   public static void main(String[] args)
   {
      try
      {
         DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
         DocumentBuilder db = dbf.newDocumentBuilder();
         Document doc = db.parse("contacts.xml");
         XPathFactory xpf = XPathFactory.newInstance();
         XPath xp = xpf.newXPath();
         XPathExpression xpe;
         xpe = xp.compile("//contact[city='Chicago']/name/text()");
         Object result = xpe.evaluate(doc, XPathConstants.NODESET);
         NodeList nl = (NodeList) result;
         for (int i = 0; i < nl.getLength(); i++)
            System.out.println(nl.item(i).getNodeValue());
      }
      catch (IOException ioe)
      {
         System.err.println("IOE: "+ioe);
      }
      catch (SAXException saxe)
      {
         System.err.println("SAXE: "+saxe);
      }
      catch (FactoryConfigurationError fce)
      {
         System.err.println("FCE: "+fce);       }
      catch (ParserConfigurationException pce)
      {
         System.err.println("PCE: "+pce);
      }
      catch (XPathException xpe)
      {
         System.err.println("XPE: "+xpe);
      }
   }
}`

在解析了contacts.xml并构建了 DOM 树之后,main()通过调用它的XPathFactory newInstance()方法实例化了XPathFactory。产生的XPathFactory实例可以通过调用它的void setFeature(String name, boolean value)方法来设置特性(比如安全处理,安全地处理 XML 文档),通过调用它的XPath newXPath()方法来创建一个XPath对象,等等。

XPath声明了一个XPathExpression compile(String expression)方法,用于编译指定的expression(一个 XPath 表达式),并将编译后的表达式作为实现XPathExpression接口的类的实例返回。当表达式不能被编译时,这个方法抛出XPathExpressionException(一个XMLException的子类)。

XPath还声明了几个重载的evaluate()方法,用于立即计算表达式并返回结果。因为计算一个表达式需要时间,所以当您计划多次计算这个表达式时,您可能会选择先编译一个复杂的表达式(以提高性能)。

编译完表达式后,main()调用XPathExpressionObject evaluate(Object item, QName returnType)方法对表达式求值。第一个参数是表达式的上下文节点,在本例中恰好是一个Document实例。第二个参数指定了由evaluate()返回的对象的种类,并被设置为XPathConstants.NODESET,这是 XPath 1.0 节点集类型的限定名,通过 DOM 的NodeList接口实现。

images 注意XPath API 将 XPath 的布尔、数字、字符串和节点集类型分别映射到 Java 的java.lang.Booleanjava.lang.DoubleStringorg.w3c.dom.NodeList类型。当调用evaluate()方法时,通过XPathConstants常量(BOOLEANNUMBERSTRINGNODESET)指定 XPath 类型,该方法负责返回适当类型的对象。XPathConstants还声明了一个NODE常量,它不映射到 Java 类型。相反,它用来告诉evaluate()您只希望结果节点集包含单个节点。

在将Object转换为NodeList之后,main()使用这个接口的getLength()item()方法来遍历节点列表。对于这个列表中的每一项,调用getNodeValue()来返回节点的值,该值随后被输出。XPathDemo产生与DOMDemo相同的输出。

高级 XPath

XPath API 提供了三个高级特性来克服 XPath 1.0 语言的局限性。这些特性是名称空间上下文、扩展函数和函数解析器,以及变量和变量解析器。

名称空间上下文

当 XML 文档的元素属于一个名称空间(包括默认名称空间)时,查询文档的 XPath 表达式必须考虑这个名称空间。对于非默认的名称空间,表达式不需要使用相同的名称空间前缀;它只需要使用相同的 URI。但是,当文档指定默认名称空间时,即使文档不使用前缀,表达式也必须使用前缀。

为了理解这种情况,假设清单 10-20 的<contacts>标签被声明为<contacts >以引入默认名称空间。此外,假设清单 10-22 在实例化DocumentBuilderFactory的行之后包含了dbf.setNamespaceAware(true);。如果您要对修改后的contacts.xml文件运行修改后的XPathDemo应用,您将看不到任何输出。

您可以通过实现javax.xml.namespace.NamespaceContext将任意前缀映射到名称空间 URI 来纠正这个问题,然后用XPath实例注册这个名称空间上下文。清单 10-23 展示了一个NamespaceContext接口的最小实现。

清单 10-23。最低限度实现NamespaceContext

`import java.util.Iterator;

import javax.xml.XMLConstants;

import javax.xml.namespace.NamespaceContext;

class NSContext implements NamespaceContext
{
   @Override
   public String getNamespaceURI(String prefix)
   {
      if (prefix == null)
         throw new IllegalArgumentException("prefix is null");
      else
      if (prefix.equals("tt"))
         return "http://www.tutortutor.ca/";
      else
         return null;
   }
   @Override
   public String getPrefix(String uri)
   {
      return null;
   }
   @Override
   public Iterator getPrefixes(String uri)    {
      return null;
   }
}`

getNamespaceURI()方法传递一个必须映射到 URI 的prefix参数。如果这个参数是null,那么必须抛出一个IllegalArgumentException对象(根据 Java 文档)。当参数是所需的前缀值时,将返回命名空间 URI。

在实例化了XPath类之后,您将实例化NSContext并通过调用XPathvoid setNamespaceContext(NamespaceContext nsContext)方法向XPath实例注册该实例。例如,您可以在XPath xp = xpf.newXPath();之后指定xp.setNamespaceContext(new NSContext());来用xp注册NSContext实例。

剩下要做的就是将前缀应用到 XPath 表达式,该表达式现在变成了//tt:contact[tt:city='Chicago']/tt:name/text(),因为contactcityname元素现在是默认名称空间的一部分,其 URI 被映射到NSContext实例的getNamespaceURI()方法中的任意前缀tt

编译并运行修改后的XPathSearch应用,你会看到John DoeBob Jones在不同的行上。

扩展函数和函数解析器

XPath API 允许您定义函数(通过 Java 方法),通过提供尚未提供的新特性来扩展 XPath 的预定义函数集。这些 Java 方法不会有副作用,因为 XPath 函数可以按任意顺序计算多次。此外,它们不能覆盖预定义的函数;永远不会执行与预定义函数同名的 Java 方法。

假设您修改了清单 10-20 的 XML 文档,以包含一个birth元素,该元素以 YYYY-MM-DD 格式记录联系人的出生日期信息。清单 10-24 显示了生成的 XML 文件。

清单 10-24。带有出生信息的基于 XML 的联系人数据库

<?xml version="1.0"?> <contacts >    <contact>       <name>John Doe</name>       <birth>1953-01-02</birth>       <city>Chicago</city>       <city>Denver</city>    </contact>    <contact>       <name>Jane Doe</name>       <birth>1965-07-12</birth>       <city>New York</city>    </contact>    <contact>       <name>Sandra Smith</name>       <birth>1976-11-22</birth>       <city>Denver</city>       <city>Miami</city>    </contact>    <contact>       <name>Bob Jones</name>       <birth>1958-03-14</birth>       <city>Chicago</city>    </contact> </contacts>

现在假设您想根据出生信息选择联系人。例如,您只想选择出生日期大于 1960 年 1 月 1 日的联系人。因为 XPath 没有为您提供这个函数,所以您决定声明一个date()扩展函数。你的第一步是声明一个实现了XPathFunction接口的Date类——参见清单 10-25 。

清单 10-25。一个扩展函数,将日期作为毫秒值返回

import java.text.ParsePosition;
import java.text.SimpleDateFormat;

import java.util.List;

import javax.xml.xpath.XPathFunction;
import javax.xml.xpath.XPathFunctionException;

import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

class Date implements XPathFunction
{
   private final static ParsePosition POS = new ParsePosition(0);
   private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-mm-dd");
   @Override
   public Object evaluate(List args) throws XPathFunctionException
   {
      if (args.size() != 1)
         throw new XPathFunctionException("Invalid number of arguments");
      String value;
      Object o = args.get(0);
      if (o instanceof NodeList)
      {
         NodeList list = (NodeList) o;
         value = list.item(0).getTextContent();
      }
      else
      if (o instanceof String)
         value = (String) o;
      else
         throw new XPathFunctionException("Cannot convert argument type");
      POS.setIndex(0);
      return sdf.parse(value, POS).getTime();
   }
}

XPathFunction声明了一个单独的Object evaluate(List args)方法,XPath 在需要执行扩展函数时会调用这个方法。向evaluate()传递一个java.util.List对象,这些对象描述由 XPath 计算器传递给扩展函数的参数。此外,这个方法返回一个适合扩展函数类型的值(date()的长整数返回类型与 XPath 的数字类型兼容)。

date()扩展函数旨在用单个参数调用,该参数可以是 nodeset 类型,也可以是 string 类型。当参数的数量(由列表的大小表示)不等于 1 时,这个扩展函数抛出XPathFunctionException

当参数类型为NodeList(节点集)时,获取节点集中第一个节点的文本内容;该内容被假定为 YYYY-MM-DD 格式的年份值(为了简洁,我忽略了错误检查)。当参数类型为String时,它被假定为这种格式的年值。任何其他类型的参数都会导致抛出一个XPathFunctionException实例。

通过将日期转换为毫秒值,简化了日期比较。这个任务是在java.text.SimpleDateFormatjava.text.ParsePosition类的帮助下完成的。重置ParsePosition对象的索引(通过setIndex(0))后,调用SimpleDateFormatDate parse(String text, ParsePosition pos)方法根据SimpleDateFormat实例化时建立的模式解析字符串,从ParsePosition索引标识的解析位置开始。这个索引在parse()方法调用之前被重置,因为parse()更新了这个对象的索引。

parse()方法返回一个java.util.Date实例,其long getTime()方法被调用以返回由解析的日期表示的毫秒数。(我在附录 C 的国际化部分讨论了SimpleDateFormatParsePositionDate。)

在实现了扩展函数之后,您需要创建一个函数解析器,它是一个对象,其类实现了XPathFunctionResolver接口,并告诉 XPath 评估器关于扩展函数的信息。清单 10-26 展示了DateResolver类。

清单 10-26。用于date()扩展函数的函数解析器

import javax.xml.namespace.QName;

import javax.xml.xpath.XPathFunction;
import javax.xml.xpath.XPathFunctionResolver;

class DateResolver implements XPathFunctionResolver
{
   private static final QName name = new QName("http://www.tutortutor.ca/",
                                               "date", "tt");
   @Override
   public XPathFunction resolveFunction(QName name, int arity)
   {
      if (name.equals(this.name) && arity == 1)
         return new Date();
      return null;
   }
}

XPathFunctionResolver声明了一个单独的XPathFunction resolveFunction(QName functionName, int arity)方法,XPath 调用该方法来识别扩展函数的名称,并获得一个 Java 对象的实例,该对象的evaluate()方法实现了该函数。

functionName参数标识函数的限定名,因为所有的扩展函数必须存在于一个名称空间中,并且必须通过一个前缀来引用(该前缀不必与文档中的前缀匹配)。因此,您还必须通过名称空间上下文将名称空间绑定到前缀(如前所述)。arity参数标识了扩展函数接受的参数数量,在重载扩展函数时非常有用。如果functionNamearity值可以接受,扩展函数的 Java 类被实例化并返回;否则,null就返回了。

最后,通过调用XPathvoid setXPathFunctionResolver(XPathFunctionResolver resolver)方法,函数解析器类被实例化并注册到 XPath 实例。

以下示例演示了在 XPath 表达式//tt:contact[tt:date(tt:birth)>tt:date('1960-01-01')]/tt:name/text()中使用date()的所有任务,该表达式仅返回出生日期大于 1960-01-01 ( Jane Doe后跟Sandra Smith)的联系人:

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setNamespaceAware(true);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse("contacts.xml");
XPathFactory xpf = XPathFactory.newInstance();
XPath xp = xpf.newXPath();
xp.setNamespaceContext(new NSContext());
xp.setXPathFunctionResolver(new DateResolver());
XPathExpression xpe;
String expr;
expr = "//tt:contact[tt:date(tt:birth)>tt:date('1960-01-01')]"+
       "/tt:name/text()";
xpe = xp.compile(expr);
Object result = xpe.evaluate(doc, XPathConstants.NODESET);
NodeList nl = (NodeList) result;
for (int i = 0; i < nl.getLength(); i++)
   System.out.println(nl.item(i).getNodeValue());
变量和变量解析器

所有以前指定的 XPath 表达式都是基于文本的。XPath 还允许您指定变量来参数化这些表达式,方式类似于在 SQL 预准备语句中使用变量。

变量出现在表达式中的方式是在其名称(可能有也可能没有名称空间前缀)前加上一个$。例如,/a/b[@c=$d]/text()是一个 XPath 表达式,它选择根节点的所有a元素,以及具有包含由变量$d标识的值的c属性的所有ab元素,并返回这些b元素的文本。这个表达式对应于清单 10-27 的 XML 文档。

清单 10-27。演示 XPath 变量的简单 XML 文档

<?xml version="1.0"?>
<a>
   <b c="x">b1</b>
   <b>b2</b>
   <b c="y">b3</b>
   <b>b4</b>
   <b c="x">b5</b>
</a>

要指定其值在表达式求值期间获得的变量,您必须用您的XPath对象注册一个变量解析器。一个变量解析器是一个类的实例,它根据它的Object resolveVariable(QName variableName)方法实现了XPathVariableResolver接口,并告诉求值器关于变量的信息。

variableName参数包含变量名的限定名——记住变量名可以以名称空间前缀为前缀。此方法验证限定名是否恰当地命名了变量,然后返回其值。

创建变量解析器后,通过调用XPathvoid setXPathVariableResolver(XPathVariableResolver resolver)方法将它注册到XPath实例中。

下面的示例演示了在 XPath 表达式/a/b[@c=$d]/text()中指定$d的所有任务,该表达式返回b1后跟b5。它假设清单 10-27 中的存储在一个名为example.xml的文件中:

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse("example.xml");
XPathFactory xpf = XPathFactory.newInstance();
XPath xp = xpf.newXPath();
XPathVariableResolver xpvr;
xpvr = new XPathVariableResolver()
       {
          @Override
          public Object resolveVariable(QName varname)
          {
             if (varname.getLocalPart().equals("d"))
                return "x";
             else
                return null;
          }
       };
xp.setXPathVariableResolver(xpvr);
XPathExpression xpe;
xpe = xp.compile("/a/b[@c=$d]/text()");
Object result = xpe.evaluate(doc, XPathConstants.NODESET);
NodeList nl = (NodeList) result;
for (int i = 0; i < nl.getLength(); i++)
   System.out.println(nl.item(i).getNodeValue());

images 注意当用名称空间前缀限定变量名时(如在$ns:d中),还必须注册一个名称空间上下文来解析前缀。

使用 XSLT 转换 XML 文档

可扩展样式表语言(XSL) 是一系列用于转换和格式化 XML 文档的语言。 XSL 转换(XSLT) 是用于将 XML 文档转换成其他格式的 XSL 语言,例如 HTML(用于通过 web 浏览器呈现 XML 文档的内容)。

XSLT 通过使用 XSLT 处理器和样式表来完成它的工作。 XSLT 处理器是一个软件组件,它将 XSLT 样式表(基于 XML 的模板由内容和转换指令组成)应用到输入文档(不修改文档),并将转换后的结果复制到结果树,该结果树可以输出到文件或输出流,甚至可以通过管道传输到另一个 XSLT 处理器进行其他转换。图 10-3 说明了转换过程。

images

图 10-3。XSLT 处理器将 XML 输入文档转换成结果树。

XSLT 的美妙之处在于,您不需要开发定制的软件应用来执行转换。相反,您只需创建一个 XSLT 样式表,并将其与需要转换到 XSLT 处理器的 XML 文档一起输入。

本节首先向您介绍 Java 的 XSLT API。然后给出了 XSLT 有用性的两个例子。

探索 XSLT API

Java 通过javax.xml.transformjavax.xml.transform.domjavax.xml.transform.saxjavax.xml.transform.staxjavax.xml.transform.stream包中的类型实现 XSLT。javax.xml.transform包定义了通用 API,用于处理转换指令,以及执行从源(XSLT 处理器的输入来源)到结果(处理器的输出发送到该处)的转换。其余的包定义了获取不同种类的源和结果的 API。

javax.xml.transform.TransformerFactory类是使用 XSLT 的起点。通过调用它的一个newInstance()方法来实例化TransformerFactory。下面的例子使用TransformerFactorystatic TransformerFactory newInstance()方法创建工厂:

TransformerFactory tf = TransformerFactory.newInstance();

在幕后,newInstance()遵循一个有序的查找过程来识别要加载的TransformerFactory实现类。该过程首先检查 javax.xml.transform.TransformerFactory系统属性,最后在找不到其他类时选择 Java 平台的默认TransformerFactory实现类。如果一个实现类不可用(也许由javax.xml.transform.TransformerFactory系统属性标识的类不存在)或者不能被实例化,newInstance()抛出一个javax.xml.transform.TransformerFactoryConfigurationError类的实例。否则,它实例化该类并返回其实例。

获得一个TransformerFactory实例后,可以调用各种配置方法来配置工厂。例如,您可以调用TransformerFactoryvoid setFeature(String name, boolean value)方法来启用一个特性(比如安全处理,安全地转换 XML 文档)。

按照工厂的配置,调用它的一个newTransformer()方法来创建和返回javax.xml.transform.Transformer类的实例。下面的例子调用Transformer newTransformer()来完成这个任务:

Transformer t = tf.newTransformer();

noargument newTransformer()方法将源输入复制到目标,不做任何修改。这种转变被称为身份转变

要更改输入,需要指定一个样式表,通过调用工厂的Transformer newTransformer(Source source)方法来完成这个任务,其中的javax.xml.transform.Source接口描述了样式表的来源。以下示例演示了这项任务:

Transformer t = tf.newTransformer(new StreamSource(new FileReader("recipe.xsl")));

这个例子创建了一个转换器,它通过一个连接到文件阅读器的javax.xml.transform.stream.StreamSource实例从名为recipe.xsl的文件中获取样式表。习惯上使用.xsl.xslt扩展名来标识 XSLT 样式表文件。

newTransformer()方法不能返回对应于工厂配置的Transformer实例时,它们抛出TransformerConfigurationException

在获得一个Transformer实例之后,您可以调用它的void setOutputProperty(String name, String value)方法来影响一个转换。javax.xml.transform.OutputKeys类声明了常用键的常量。例如,OutputKeys.METHOD是指定输出结果树的方法的键(如 XML、HTML、纯文本或其他)。

images 提示要在一个方法调用中设置多个属性,创建一个java.util.Properties对象,并将该对象作为参数传递给Transformervoid setOutputProperties(Properties prop)方法。由setOutputProperty()setOutputProperties()设置的属性覆盖样式表的xsl:output指令设置。

在执行转换之前,您需要获得实现Sourcejavax.xml.transform.Result接口的类的实例。然后将这些实例传递给Transformervoid transform(Source xmlSource, Result outputTarget)方法,当转换过程中出现问题时,该方法抛出一个javax.xml.transform.TransformerException类的实例。

以下示例说明如何获取源和结果,并执行转换:

Source source = new DOMSource(doc);
Result result = new StreamResult(System.out);
t.transform(source, result);

第一行实例化了javax.xml.transform.dom.DOMSource类,它作为一个 DOM 树的持有者,这个 DOM 树植根于由doc指定的Document对象。第二行实例化了javax.xml.transform.stream.StreamResult类,它充当标准输出流的容器,转换后的数据被发送到该输出流。第三行从Source实例读取数据,并将转换后的数据输出到Result实例。

images 提示尽管 Java 的默认转换器支持位于javax.xml.transform.domjavax.xml.transform.saxjavax.xml.transform.staxjavax.xml.transform.stream包中的各种SourceResult实现类,但非默认转换器(可能通过javax.xml.transform.TransformerFactory系统属性指定)可能会受到更多限制。因此,每个SourceResult实现类都声明了一个FEATURE字符串常量,可以传递给TransformerFactoryboolean getFeature(String name)方法。当支持SourceResult实现类时,该方法返回 true。例如,当支持流源时,tf.getFeature(StreamSource.FEATURE)返回 true。

javax.xml.transform.sax.SAXTransformerFactory类提供了您可以使用的额外的特定于 SAX 的工厂方法,但是只有当TransformerFactory实例也是该类的实例时才可以使用。为了帮助您做出决定,SAXTransformerFactory还声明了一个FEATURE字符串常量,您可以将它传递给getFeature()。例如,当从tf引用的转换器工厂是SAXTransformerFactory的实例时,tf.getFeature(SAXTransformerFactory.FEATURE)返回 true。

大多数 JAXP 接口实例和返回它们的工厂都不是线程安全的。这种情况也适用于变压器。尽管您可以在同一线程上多次重用同一个转换器,但是您不能从多个线程访问该转换器。

这个问题可以通过使用实现javax.xml.transform.Templates接口的类的实例来解决。这个接口的 Java 文档是这样说的:对于一个并发运行的多线程的给定实例,模板必须是线程安全的,并且可以在一个给定的会话中多次使用。除了促进线程安全之外,Templates实例还可以提高性能,因为它们代表编译的 XSLT 样式表。

以下示例显示了如何在没有Templates对象的情况下执行转换:

TransformerFactory tf = TransformerFactory.newInstance();
StreamSource ssStyleSheet = new StreamSource(new FileReader("recipe.xsl"));
Transformer t = tf.newTransformer(ssStyleSheet);
t.transform(new DOMSource(doc), new StreamResult(System.out));

您不能从多个线程访问t的转换器。相比之下,下面的例子向您展示了如何从一个Templates对象构建一个转换器,以便可以从多个线程访问它:

TransformerFactory tf = TransformerFactory.newInstance(); StreamSource ssStyleSheet = new StreamSource(new FileReader("recipe.xsl")); Templates te = tf.newTemplates(ssStylesheet); Transformer t = te.newTransformer(); t.transform(new DOMSource(doc), new StreamResult(System.out));

不同之处在于调用TransformerfactoryTemplates newTemplates(Source source)方法来创建并返回其类实现了Templates接口的对象,以及调用该接口的Transformer newTransformer()方法来获得Transformer实例。

演示 XSLT API

清单 10-15 展示了一个DOMDemo应用,它基于清单 10-2 的电影 XML 文档创建一个 DOM 文档树。不幸的是,不可能使用 DOM API 将ISO-8859-1分配给 XML 声明的encoding属性。此外,不可能使用 DOM 将该树输出到文件或其他目的地。这些问题可以通过使用 XSLT 来解决,如下例所示:

TransformerFactory tf = TransformerFactory.newInstance();
Transformer t = tf.newTransformer();
t.setOutputProperty(OutputKeys.METHOD, "xml");
t.setOutputProperty(OutputKeys.ENCODING, "iso-8859-1");
t.setOutputProperty(OutputKeys.INDENT, "yes");
t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "3");
Source source = new DOMSource(doc);
Result result = new StreamResult(System.out);
t.transform(source, result);

在创建了一个 transformer 工厂并从这个工厂获得了一个 transformer 之后,指定了四个输出属性来影响转换。OutputKeys.METHOD指定结果树将被写成 XML,OutputKeys.ENCODING指定ISO-8859-1将是 XML 声明的encoding属性的值,OutputKeys.INDENT指定转换器可以输出额外的空白。

额外的空白用于跨多行输出 XML,而不是在一行中输出。因为指出缩进 XML 行的空格数会很好,而且因为这个信息不能通过OutputKeys属性指定,所以非标准的"{http://xml.apache.org/xslt}indent-amount"属性(属性键以大括号分隔的 URIs 开始)被用来指定一个适当的值(比如 3 个空格)。在这个例子中指定这个属性是可以的,因为 Java 的默认 XSLT 实现是基于 Apache 的 XSLT 实现的。

设置属性后,获得一个源(DOM 文档树)和一个结果(标准输出流),调用transform()将源转换为结果。

虽然这个例子向您展示了如何输出一个 DOM 树,以及如何为生成的 XML 文档的 XML 声明指定一个encoding值,但是这个例子并没有真正展示 XSLT 的强大功能,因为(除了设置encoding属性值之外)它执行一个身份转换。一个更有趣的例子是利用样式表。

考虑这样一个场景,您想要将清单 10-1 的菜谱文档转换成 HTML 文档,以便通过 web 浏览器呈现。清单 10-28 展示了一个样式表,转换器可以用它来执行转换。

清单 10-28。将菜谱文档转换成 HTML 文档的 XSLT 样式表

<?xml version="1.0"?>
<xsl:stylesheet version="1.0"
                xmlns:xsl="http://www.w3.org/1999/xsl/transform">
<xsl:template match="/recipe">
<html>
   <head>
      <title>Recipes</title>
   </head>

   <body>
      <h2>
         <xsl:value-of select="normalize-space(title)"/>
      </h2>

      <h3>Ingredients</h3>

      <ul>
      <xsl:for-each select="ingredients/ingredient">
        <li>
           <xsl:value-of select="normalize-space(text())"/>
           <xsl:if test="@qty"> (<xsl:value-of select="@qty"/>)</xsl:if>
        </li>
      </xsl:for-each>
      </ul>

      <h3>Instructions</h3>

      <xsl:value-of select="normalize-space(instructions)"/>
   </body>
</html>
</xsl:template>
</xsl:stylesheet>

清单 10-28 揭示了样式表是一个 XML 文档。它的根元素是stylesheet,标识样式表的标准名称空间。习惯上指定xsl作为引用 XSLT 指令元素的名称空间前缀,尽管可以指定任何前缀。

样式表基于控制元素及其内容如何转换的template元素。模板关注于通过match属性识别的单个元素。这个属性的值是一个 XPath 位置路径表达式,它匹配根元素节点的所有recipe子节点。关于清单 10-1 ,将只匹配和选择单个recipe根元素。

一个template元素可以包含文字文本和样式表指令。例如,<xsl:value-of select="normalize-space(title)"/>中的value-of指令指定检索title元素的值(它是recipe上下文节点的子节点)并将其复制到输出中。因为该文本被空格和换行符包围,所以在复制标题之前,调用 XPath 的normalize-string()函数来删除这些空格。

XSLT 是一种功能强大的声明性语言,包括控制流指令,如for-eachif。在<xsl:for-each select="ingredients/ingredient">的上下文中,for-each使得ingredients节点的所有ingredient子节点被一次一个地选择和处理。对于每个节点,执行<xsl:value-of select="normalize-space(text())"/>来复制 ingredient节点的内容,规范化以删除空白。此外,<xsl:if test="@qty"> (<xsl:value-of select="@qty"/>)中的if指令确定配料节点是否有一个qty属性,并且(如果有)将一个空格字符和该属性值(用括号括起来)复制到输出中。

images 注意除了这个简短的例子之外,XSLT 还有很多内容可以展示。要了解更多关于 XSLT 的知识,我推荐您阅读林洋·坦尼森写的一本关于从新手到专业人员的【XSLT 2.0 入门 ( [www.apress.com/9781590593240](http://www.apress.com/9781590593240))。XSLT 2.0 是 XSLT 1.0 的超集—Java 7 支持 XSLT 1.0。

以下摘自本书代码中包含的一个XSLTDemo应用,向您展示了如何编写 Java 代码来通过清单 10-28 的样式表处理清单 10-1 :

DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse("recipe.xml");
TransformerFactory tf = TransformerFactory.newInstance();
StreamSource ssStyleSheet;
ssStyleSheet = new StreamSource(new FileReader("recipe.xsl"));
Transformer t = tf.newTransformer(ssStyleSheet);
t.setOutputProperty(OutputKeys.METHOD, "html");
t.setOutputProperty(OutputKeys.INDENT, "yes");
Source source = new DOMSource(doc);
Result result = new StreamResult(System.out);
t.transform(source, result);

这段摘录揭示了 output 方法被设置为html,也揭示了结果 HTML 应该缩进。然而,输出只是部分缩进,如清单 10-29 所示。

清单 10-29。清单 10-1 的食谱文档的 HTML 等价物

`

<head> <META http-equiv="content-type" content="text/html; charset=utf-8"> <title>Recipes</title> </head> <body> <h2>Grilled Cheese Sandwich</h2> <h3>Ingredients</h3> <ul> <li>bread slice (2)</li> <li>cheese slice</li> <li>margarine pat (2)</li> </ul> <h3>Instructions</h3>Place frying pan on element and select medium heat. For each bread slice, smear one pat of margarine on one side of bread slice. Place cheese slice between bread slices with margarine-smeared sides away from the cheese. Place sandwich in frying pan with one` `margarine-smeared side in contact with pan. Fry for a couple of minutes and flip. Fry other side for a minute and serve.</body> </html>`

OutputKeys.INDENT和它的"yes"值允许你跨多行输出 HTML,而不是在一行中输出 HTML。然而,XSLT 处理器不执行额外的缩进,并忽略通过代码(如t.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "3");)指定缩进的空格数的尝试。

images 注意OutputKeys.METHOD设置为"html"时,XSLT 处理器输出一个<META>标签。

练习

以下练习旨在测试您对 XML 文档创建以及 SAX、DOM、StAX、XPath 和 XSLT APIs 的理解:

  1. 用一个books根元素创建一个books.xml文档文件。books元素必须包含一个或多个book元素,其中book元素必须包含一个title元素、一个或多个author元素和一个publisher元素(按此顺序)。此外,book元素的<book>标签必须包含isbnpubyear属性。在第一个book元素中记录Advanced C++ / James Coplien / Addison Wesley / 0201548550 / 1992,在第二个book元素中记录Beginning Groovy and Grails/Christopher M. Judd/Joseph Faisal Nusairat/James Shingler/Apress/9781430210450/2008,在第三个book元素中记录Effective Java / Joshua Bloch / Addison Wesley / 0201310058 / 2001
  2. 修改books.xml以包含满足练习 1 要求的内部 DTD。使用清单 10-10 的SAXDemo应用来验证books.xml的 DTD ( java SAXDemo books.xml -v)。
  3. 创建一个SAXSearch应用,它在books.xml中搜索那些book元素,这些元素的publisher子元素包含的文本等于应用的单个命令行发布者名称参数。一旦有匹配,输出title元素的文本,后跟book元素的isbn属性值。例如,java SAXSearch Apress应该输出title = Beginning Groovy and Grails, isbn = 9781430210450,而java SAXSearch "addison wesley"应该输出title = Advanced C++, isbn = 0201548550,然后在单独的行上输出title = Effective Java, isbn = 0201310058。如果命令行发布者名称参数与publisher元素的文本不匹配,则不应输出任何内容。
  4. 创建一个相当于练习 3 的SAXSearch应用的DOMSearch应用。
  5. 创建一个ParseXMLDoc应用,它使用一个基于 StAX 流的阅读器来解析它的单个命令行参数,一个 XML 文档。在创建了这个阅读器之后,应用应该验证已经检测到了一个START_DOCUMENT信息集项目,然后进入一个循环,读取下一个项目,并使用一个 switch 语句输出一个与已经读取的项目相对应的消息:ATTRIBUTECDATACHARACTERSCOMMENTDTDEND_ELEMENTENTITY_DECLARATIONENTITY_REFERENCENAMESPACENOTATION_DECLARATIONPROCESSING_INSTRUCTIONSPACESTART_ELEMENT。检测到START_ELEMENT时,输出该元素的名称和本地名称,输出所有属性的本地名称和值。当检测到END_DOCUMENT信息集项目时,循环结束。显式关闭流读取器,然后关闭它所基于的文件读取器。用练习 1 的books.xml文件测试这个应用。
  6. 通过将<name>John Doe</name>更改为<Name>John Doe</Name>来修改清单 10-20 的联系人文档。因为当您运行清单 10-22 的XPathSearch应用时,您在输出中不再看到John Doe(您只看到Bob Jones),所以修改这个应用的位置路径表达式,以便您看到John Doe后面跟着Bob Jones
  7. 创建一个books.xsl样式表文件和一个MakeHTML应用,其结构与处理清单 10-28 的recipe.xsl样式表的应用相似。MakeHTML使用books.xsl将练习 1 的books.xml内容转换成 HTML。当在网络浏览器中查看时,HTML 应该会产生一个类似于图 10-4 所示页面的网页。

images

图 10-4。练习 1 的books.xml内容通过网页呈现。

总结

应用经常使用 XML 文档来存储和交换数据。在理解这些文档之前,您需要理解 XML。这种理解需要了解 XML 声明、元素和属性、字符引用和 CDATA 节、名称空间、注释和处理指令。它还包括了解文档的良好格式意味着什么,以及就 dtd 和基于 XML 模式的模式而言,文档的有效性意味着什么。

您还需要学习如何通过 JAXP 的 SAX、DOM、StAX、XPath 和 XSLT APIs 处理 XML 文档。SAX 用于通过回调范式解析文档,DOM 用于从节点树解析和创建文档,StAX 用于在基于流或基于事件的上下文中解析和创建文档,XPath 用于以比 DOM API 更简洁的方式搜索节点树,XSLT(在 XPath 的帮助下)用于将 XML 内容转换为 XML、HTML 或其他格式。

现在您已经理解了 XML 和用于处理 XML 文档的 JAXP API,您将在第十一章中很好地运用这些知识,在那里您将了解 Java 对 web 服务的支持。*

十一、使用 Web 服务

Web 服务很流行,应用也很广泛,Java 支持它们的开发。本章向您展示了如何使用 Java 的 web 服务开发特性来创建您自己的 web 服务和/或访问他人创建的 web 服务。

第十一章首先向你介绍 web 服务的主题,强调基于 SOAP 和 RESTful 类别。本章随后揭示了 Java 在其面向 web 服务的 API、注释和工具方面对 web 服务开发的支持。您还将了解 Java 的轻量级 HTTP 服务器,它用于将您的 web 服务部署到一个简单的 web 服务器上,并在这个环境中测试它们。

有了对 web 服务和 Java 对其开发的支持的基本理解,接下来您将学习如何开发基于 SOAP 和 RESTful 的 web 服务。对于每个 web 服务类别,您将学习如何创建和访问自己的 web 服务,然后学习如何访问外部 web 服务。

第十一章最后介绍了五个高级 web 服务主题:通过 SAAJ API 访问基于 SOAP 的 web 服务,安装 JAX-WS 处理程序来记录 SOAP 消息流,安装定制的轻量级 HTTP 服务器来执行身份验证,从 RESTful web 服务向客户端发送附件,以及使用提供者的调度客户端。

什么是 Web 服务?

web 服务还没有标准的定义,因为这个术语对不同的人有不同的含义。比如有人把 web 服务定义为 web 应用;其他人根据应用用来在 web 上通信的协议(例如,SOAP)来定义 Web 服务。也许定义 web 服务的最佳方式是首先定义这个术语的各个部分:

  • Web : 一个巨大的互联资源网络,其中资源是统一资源标识符(URI)命名的数据源,如电子表格文档、数字化视频、网页,甚至是应用。这些资源可以通过标准的互联网协议(例如,HTTP 或 SMTP)来访问。
  • 服务 : 基于服务器的应用或软件组件,根据消息交换模式(MEP)——参见[en.wikipedia.org/wiki/Message_Exchange_Pattern](http://en.wikipedia.org/wiki/Message_Exchange_Pattern)——通过消息交换向客户端公开资源。请求-响应 MEP 是典型的。

有了这些定义,我们可以将 web 服务定义为基于服务器的应用/软件组件,它通过消息交换向客户端公开基于 web 的资源。这些消息可能基于也可能不基于 XML,并且可以被认为是调用 web 服务功能和接收调用结果。图 11-1 说明了这种信息交换。

images

图 11-1。客户端与 web 服务交换消息以访问资源。

images 注意 Web 服务是面向服务架构(SOA) 的一种实现——参见[www.xml.com/lpt/a/1292](http://www.xml.com/lpt/a/1292)。将 SOA 视为一组设计原则或框架,用于将业务逻辑实现为可重用的服务,这些服务可以以不同的方式组合起来,以满足不断发展的业务需求。SOA 关注的是规范,而不是实现。

Web 服务可以分为简单的和复杂的。简单 web 服务不与其他 web 服务交互;例如,一个独立的基于服务器的应用,具有一个返回指定时区的当前时间的函数。相反,复杂的 web 服务经常与其他 web 服务交互。例如,一个通用的社交网络 web 服务可能会与 Twitter 和脸书 web 服务进行交互,以获取特定个人的所有 Twitter 和所有脸书信息,并将其返回给客户端。复杂的 web 服务也被称为 mashups ,因为它们mashup(组合)来自多个 web 服务的数据。

网络服务的基本原理

公司在历史上依赖于客户机/服务器系统,其中客户机应用通过夹在它们之间的基于服务器的中间件软件与基于服务器的后端软件通信。传统的中间件一直受到各种问题的困扰,例如获取和维护成本高、无法通过互联网与后端软件和客户端应用通信以及不灵活。

Web 服务是一种基于 Web 和(典型的)XML 的新型中间件。它们克服了这些和其他传统中间件的问题,因为它们基于自由和开放的标准,因为它们的可维护性,因为它们涉及网络,因为它们是灵活的。例如,不同于传统的基于远程过程调用(RPC)的中间件(参见[en.wikipedia.org/wiki/Remote_procedure_call](http://en.wikipedia.org/wiki/Remote_procedure_call)中对 RPC 的简要介绍),它依赖于紧密耦合的连接(当应用被修改时很容易中断,从而导致维护问题),RESTful web 服务(稍后讨论)依赖于松散耦合的连接,这将应用更改的影响最小化。web 服务接口(通常是一个 XML 文件)提供了客户端和服务器软件之间的抽象,因此更改其中一个组件不会自动要求更改另一个组件。维护成本降低了,可重用性提高了,因为相同的接口使得在其他应用中重用 web 服务变得更加容易。

web 服务的另一个好处是它们保留了公司在遗留软件上的大量投资。为了满足不断发展的业务需求(这可能是一项成本高昂的任务),不必从头开始重写该软件(通常是用各种语言编写的),该软件可以通过 web 服务向客户端公开,web 服务可以与其他 web 服务混合,以经济高效的方式实现这些需求。

基于 SOAP 的网络服务

基于 SOAP 的 web 服务是一种广泛使用的基于 SOAP 的 web 服务类别,SOAP 是一种 XML 语言,用于定义网络连接两端都能理解的消息(抽象函数调用或它们的响应)。SOAP 消息的交换被称为操作,对应于一个函数调用及其响应,如图图 11-2 所示。

images

图 11-2。web 服务操作由输入和输出消息组成。

相关的操作经常被分组到一个接口中,这个接口在概念上类似于 Java 接口。一个绑定提供了接口如何绑定到消息协议(特别是 SOAP)的具体细节,以便通过网络传递命令、错误代码和其他项目。

一个绑定和一个网络地址(一个 IP 地址和一个端口)URI 的组合被称为一个端点,端点的集合就是一个 web 服务。图 11-3 展示了这种架构。

images

图 11-3。操作的接口可以通过它们的端点来访问。

尽管 SOAP 可以单独使用,正如本章后面对 SAAJ API 的讨论中所展示的,但是 SOAP 通常与 Web 服务描述语言 (WSDL,发音为 whiz-dull),一种用于定义服务所提供的操作的 XML 语言一起使用。不像 WSDL,SOAP,曾经代表简单对象访问协议,不再被认为是一个缩写。(SOAP 既不简单,也不与对象相关。)

WSDL 文档是基于 SOAP 的 web 服务和它的客户端之间的正式契约,提供了与 web 服务交互所需的所有细节。这个文档允许您将消息分组到操作中,将操作分组到接口中。它还允许您为每个接口以及端点地址定义一个绑定。在本章的后面部分,你将在学习如何创建一个基于 SOAP 的 web 服务的同时,探索 WSDL 文档架构。

除了支持 WSDL 文档,基于 SOAP 的 web 服务还具有以下属性:

  • 解决复杂的非功能性需求的能力,比如安全性和事务:这些需求通过各种各样的规范变得可用。为了促进这些规范之间的互操作性,一个被称为 Web 服务互操作性组织 (WS-I)的行业联盟成立了。WS-I 已经建立了一组概要文件,其中一个概要文件是在特定修订级别上的一组命名的 web 服务规范,以及一组实现和互操作性指南,推荐如何使用这些规范来开发可互操作的 web 服务。例如,第一个概要文件 WS-I Basic Profile 1.0 由以下一组非专有的 web 服务规范组成:SOAP 1.1、WSDL 1.1、UDDI 2.0、XML 1.0(第二版)、XML 模式第一部分:结构、XML 模式第二部分:数据类型、RFC2246:传输层安全协议版本 1.0、RFC2459: Internet X.509 公钥基础设施证书和 CRL 概要、RFC2616:超文本传输协议 1.1、RFC 2818:HTT 其他概要文件示例包括 WS-I 基本安全概要文件和简单 SOAP 绑定概要文件。有关这些和其他概要的更多信息,请访问 WS-I 网站[www.ws-i.org/](http://www.ws-i.org/)。Java 7 支持 WS-I 基本概要。
  • 与 web 服务异步交互的能力 : Web 服务客户端应该能够以非阻塞、异步的方式与 Web 服务交互。Java 7 中提供了对 web 服务操作的客户端异步调用支持。

基于 SOAP 的 web 服务在一个包括服务请求者(客户机)、服务提供者和服务代理的环境中执行。这种环境如图图 11-4 所示。

images

图 11-4。基于 SOAP 的 web 服务包括服务请求者、服务提供者和服务代理(例如 UDDI)。

服务请求者,通常是客户端应用(例如,web 浏览器),或者可能是另一个 web 服务,首先以某种方式定位服务提供者。例如,服务请求者可能向服务代理发送一个 WSDL 文档,服务代理用另一个标识服务提供者位置的 WSDL 文档进行响应。然后,服务请求者通过 SOAP 消息与服务提供者通信。

需要发布服务提供商,以便其他人可以找到并使用它们。2000 年 8 月,一项名为通用描述、发现和集成 (UDDI)的开放行业倡议启动,让企业发布服务列表、相互发现并定义服务或软件应用如何在互联网上交互。然而,这种独立于平台、基于 XML 的注册中心没有被广泛采用,目前也没有使用。许多开发人员发现 UDDI 过于复杂且缺乏功能,于是选择了其他方式,比如在网站上发布信息。例如,谷歌通过其[code.google.com/more/](http://code.google.com/more/)网站提供其公共网络服务(如谷歌地图)。

在服务请求者和服务提供者之间流动的 SOAP 消息通常是不可见的,作为请求和响应在他们的 web 服务协议栈的 SOAP 库之间传递(参见[en.wikipedia.org/wiki/Web_services_protocol_stack](http://en.wikipedia.org/wiki/Web_services_protocol_stack))。然而,正如您将在本章后面发现的那样,直接访问这些消息是可能的。

images 注意基于 SOAP 的 web 服务也被称为大型 web 服务,因为它们基于许多规范,比如前面提到的 WS-I 规范。

RESTful Web 服务

基于 SOAP 的 web 服务可以通过各种协议来交付,例如 HTTP、SMTP、FTP 和更新的 Blocks 可扩展交换协议——参见[www.rfc-editor.org/rfc/rfc3080.txt](http://www.rfc-editor.org/rfc/rfc3080.txt)。通过 HTTP 传递 SOAP 消息可以被认为是 RESTful web 服务的一个特例。

表述性状态转移(REST) 是一种用于分布式超媒体系统(图像、文本和其他资源位于网络周围并可通过超链接访问的系统)的软件架构风格。web 服务上下文中感兴趣的超媒体系统是万维网。

images Roy Fielding(超文本传输协议[HTTP]规范 1.0 和 1.1 版本的主要作者之一,Apache 软件基金会的共同创始人)早在 2000 年的博士论文中就引入并定义了 REST。(Fielding 认为 REST 是 Web 的架构风格,尽管他是在 Web 成为一个持续关注的事物之后很久才写的。)REST 被广泛认为是基于 SOAP 的 web 服务日益增长的复杂性的解决方案。

REST 的核心部分是 URI 可识别的资源。REST 通过多用途互联网邮件扩展(MIME)类型来标识资源(比如text/xml)。此外,资源具有由它们的表示捕获的状态。当客户机从 RESTful web 服务请求资源时,服务向客户机发送资源的 MIME 类型表示。

客户端使用 HTTP 的POSTGETPUTDELETE动词来检索资源的表示并操作资源——REST 将这些动词视为 API,并将它们映射到数据库创建、读取、更新和删除(CRUD)操作上(参见[en.wikipedia.org/wiki/Create,_read,_update_and_delete](http://en.wikipedia.org/wiki/Create,_read,_update_and_delete)中的 CRUD 介绍)。表 11-1 揭示了这种映射。

每个动词后面都有一个标识资源的 URI。(这种非常简单的方法与 SOAP 向单个资源发送编码消息的方法根本不兼容。)URI 可能指的是一个集合,比如[tutortutor.ca/library](http://tutortutor.ca/library),也可能指的是集合中的一个元素,比如[tutortutor.ca/library/9781430234135](http://tutortutor.ca/library/9781430234135)——这些 URIs 只是举例说明。

对于POSTPUT请求,基于 XML 的资源数据作为请求体传递。例如,您可以将POST http://tutortutor.ca/library HTTP/ 1.1(其中HTTP/ 1.1描述了请求者的 HTTP 版本)解释为将POST的 XML 数据插入到[tutortutor.ca/library](http://tutortutor.ca/library)集合资源中的请求。

对于GETDELETE请求,数据通常作为查询字符串传递,其中查询字符串是 URI 中以“?字符开头的部分。例如,GET http://tutortutor.ca/library可能会返回图书馆资源中所有书籍的标识符列表,GET http://tutortutor.ca/library?isbn=9781430234135可能会返回书籍资源的表示,其查询字符串标识国际标准书号(ISBN) 9781430234135

images 注意要获得 HTTP 动词与其 CRUD 对应项之间映射的完整描述,请查看维基百科的“表述性状态转移”条目([en.wikipedia.org/wiki/Representational_State_Transfer](http://en.wikipedia.org/wiki/Representational_State_Transfer))中的“RESTful Web 服务 HTTP 方法”表。

除了在发出请求时依赖 HTTP 动词和 MIME 类型之外,REST 还依赖 HTTP 的标准响应代码(如 404(未找到请求的资源)和 200(资源操作成功))以及 MIME 类型(当检索资源表示时)来获得响应。

images 提示如果您想知道是使用 SOAP 还是 REST 来开发 web 服务,请查看“RESTful Web 服务与“大型”Web 服务:做出正确的架构决策”([www.jopera.org/files/www2008-restws-pautasso-zimmermann-leymann.pdf](http://www.jopera.org/files/www2008-restws-pautasso-zimmermann-leymann.pdf))。

Java 和网络服务

在 Java 6 之前,基于 Java 的 web 服务是专门用 Java EE SDK 开发的。虽然从生产的角度来看,Java EE 是开发 web 服务的首选方法,因为基于 Java EE 的服务器提供了非常高的可伸缩性、安全基础设施、监控设施等等,但是将 web 服务重复部署到 Java EE 容器是非常耗时的,并且会降低开发速度。

Java 6 通过将 API、注释、工具和一个轻量级 HTTP 服务器(用于将 web 服务部署到一个简单的 web 服务器,并在这个环境中测试它们)合并到其核心中,简化并加速了 web 服务的开发。Java 7 也支持这些组件。

为核心 JAVA 争议增加 WEB 服务支持

许多人认为 Sun Microsystems 不应该在 Java 6 中增加对 web 服务的支持。一种批评是 JAX-WS(主要的 web 服务 API)鼓励自底向上的方法来构建 web 服务——首先开发一个 Java 类,然后开发 WSDL 契约。相反,那些喜欢自顶向下方法的人认为首先创建 WSDL 和模式为互操作性提供了最好的机会(特别是当连接两端的技术和平台不同时),因为这样做鼓励了基于接口的设计方法,提供了最大的重用和互操作性。

Davanum Srinivas 在他的“为什么在 Java6 中捆绑 JAX-WS 是一个坏主意!”博文([blogs.cocoondev.org/dims/archives/004717.html](http://blogs.cocoondev.org/dims/archives/004717.html))。首先,他指出需要依靠 Java 认可的标准覆盖机制(见[download.oracle.com/javase/6/docs/technotes/guides/standards/](http://download.oracle.com/javase/6/docs/technotes/guides/standards/))来使用 JAX-WS 的后续版本(带有新特性和/或错误修复)。例如,JAX-WS 2.0 附带了 Java 6。要使用它的 JAX-WS 2.1 后继版本,您必须使用 Java 认可的标准覆盖机制,如 Vivek Pandey 的“JDK 6 中的 web 服务”博客文章([weblogs.java.net/blog/vivekp/archive/2006/12/webservices_in.html](http://weblogs.java.net/blog/vivekp/archive/2006/12/webservices_in.html))中所述。Srinivas 的第二个抱怨是 Java 6 的 web 服务实现不支持 WS-I 概要文件,比如 WS-Security。

将 web 服务支持集成到 Java6 中的 Sun Microsystems 团队的成员 Arun Gupta 在他的博客文章“Java 6 中的 Web 服务本地支持”中反驳了这些批评。

Web 服务 API

Java 提供了几个支持 web 服务的 API。除了我在第十章中讨论的各种 JAXP API 之外(除了 web 服务之外也使用这些 API),Java 还提供了 JAX-WS、JAXB 和 SAAJ API:

  • Java API for XML Web Services(JAX-WS):构建通过 XML 通信的 Web 服务和客户机(用 Java)的主要 API。JAX-WS 取代了旧的 Java 远程过程调用 Web 服务(JAX-RPC) API,并被分配了包javax.xml.ws和各种子包。Java 7 支持 JAX-WS 2.2.4。
  • Java Architecture for XML Binding(JAXB):将基于 XML 模式的数据类型映射到 Java 对象的 API,反之亦然——参见第十章了解 XML 模式。JAX-WS 将数据绑定任务委托给 JAXB。这个 API 被分配了包javax.xml.bind和各种子包。Java 7 支持 JAXB 2.2.4。
  • Soap with Attachments API for Java(SAAJ):创建、发送和接收带/不带附件的 Soap 消息的 API。根据 Jitendra Kotamraju 在[weblogs.java.net/blog/jitu/archive/2007/09/no_saaj_ri_depe_1.html](http://weblogs.java.net/blog/jitu/archive/2007/09/no_saaj_ri_depe_1.html)发表的“JAX-WS RI 中没有 SAAJ RI 依赖”的博客文章,JAX-WS 对于 SOAP 消息对 SAAJ 的依赖在 JAX-WS 2.1.3 的参考实现中被移除(称为 Metro ,参见[jax-ws.java.net/](http://jax-ws.java.net/))。这个 API 被分配了javax.xml.soap包。Java 7 支持 SAAJ 1.3。

我将在这一章探索 JAX-WS 和 SAAJ,但是(为了简洁)不会探索 JAXB。如果您想要关于这个 API 的详细教程,我建议您查看位于[jaxb.java.net/tutorial/](http://jaxb.java.net/tutorial/)的广泛的 JAXB 教程。

Web 服务注释

Java 6 引入了几种便于 web 服务开发的 web 服务注释类型,允许您通过元数据以声明方式描述 web 服务——参见第三章的注释介绍。没有这些注释类型,您仍然可以开发 web 服务,但是如果您决定不使用它们,您将很快体会到它们的便利。

大多数 web 服务注释类型要么是 Web 服务元数据 API 的一部分(参见 http://jcp.org/en/jsr/detail?id=181),它被分配了包javax.jwsjavax.jws.soap,要么属于javax.xml.ws包。javax.jws包提供了以下注释类型:

  • 将 web 服务与外部定义的处理程序链相关联。我将在本章后面从客户端的角度讨论处理程序链。
  • Oneway表示给定的@WebMethod注释只有输入消息,没有输出消息。
  • 定制作为 web 服务操作公开的方法。
  • WebParam定制单个参数到 WSDL message元素的part元素的映射。
  • WebResult定制返回值到 WSDL message元素的part元素的映射。
  • WebService将 Java 类标记为实现 web 服务,或者将 Java 接口标记为定义服务端点接口。

以下注释类型属于javax.jws.soap包(其中三种不推荐使用,支持使用HandlerChain注释类型):

  • InitParam描述初始化参数(在初始化期间传递给处理程序的名称/值对)。此批注类型已被否决。
  • 指定 web 服务到 SOAP 协议的映射。
  • 指定在 web 服务的业务方法之前和之后运行的单个 SOAP 消息处理程序。调用此处理程序是为了响应以服务为目标的 SOAP 消息。此批注类型已被否决。
  • SOAPMessageHandlers指定在 web 服务的业务方法之前和之后运行的 SOAP 协议处理程序的列表。调用这些处理程序是为了响应以服务为目标的 SOAP 消息。此批注类型已被否决。

最后,从 RESTful webservice 的角度来看,javax.xml.ws最重要的注释类型是WebServiceProviderBinding。我将在本章后面讨论这些注释类型。

网络服务工具

Java 提供了四个基于命令行的工具来促进 web 服务开发。其中两个工具用于在基于 XML 模式的模式(参见第十章)和 Java 类之间进行转换,另一对工具用于 WSDL 文档的上下文中:

  • schemagen : WSDL 文档使用 XML Schema 数据类型来描述 web 服务函数返回和参数类型。这个工具从 Java 类生成一个模式(通常存储在扩展名为.xsd的文件中)——为每个引用的名称空间创建一个模式文件。创建模式后, XML 实例文档(符合其模式的 XML 文档)可以通过 JAXB 在 Java 对象之间进行转换。这些类包含 JAXB 解析 XML 所需的所有信息,用于封送(将 Java 对象转换成 XML)和解封(将 XML 转换成 Java 对象)——应用不执行 XML 解析。
  • wsgen:这个工具读取一个已编译的 web 服务端点接口,并为 web 服务部署和调用生成 JAX-WS 可移植工件。它也可以生成一个 WSDL 文件和相应的 XML 模式文档(当指定了它的-wsdl选项时)。通过Endpoint.publish()发布 web 服务时不需要这个工具,它会自动生成工件和 WSDL/模式。你将在本章的后面了解到Endpoint.publish()
  • 这个工具从给定的 WSDL 文档中生成客户端支持的 Java 类(工件)。这些类有助于针对服务编写客户端。
  • 这个工具从模式中生成 Java 类。生成的类包含映射到模式中定义的 XML 元素和属性的属性。

为了简洁,我在这一章只演示了wsimport。对于schemagenxjc的演示,请分别查看“使用 JAXB schemagen 工具从 Java 类生成 XML 模式文件”([publib.boulder.ibm.com/infocenter/wasinfo/v7r0/index.jsp?topic=/com.ibm.websphere.express.doc/info/exp/ae/twbs_jaxbjava2schema.html](http://publib.boulder.ibm.com/infocenter/wasinfo/v7r0/index.jsp?topic=/com.ibm.websphere.express.doc/info/exp/ae/twbs_jaxbjava2schema.html) ) 和“用于 XML 绑定的 Java 架构(JAXB)”([www.oracle.com/technetwork/articles/javase/index-140168.html](http://www.oracle.com/technetwork/articles/javase/index-140168.html))。

轻量级 HTTP 服务器

Java 7 参考实现包括一个用于部署和测试 web 服务的轻量级 HTTP 服务器。服务器实现支持 HTTP 和 HTTPS 协议,其相关的 API 可用于创建定制的 web 服务器,以增强您的 web 服务测试或用于其他目的。

服务器的 API 不是 Java 的正式部分,这意味着它不能保证是非引用 Java 实现的一部分。因此,轻量级 HTTP 服务器 API 存储在以下包中,而不是分布在java.net.httpserverjava.net.httpserver.spi等包中:

  • com.sun.net.httpserver:这个包为构建嵌入式 HTTP 服务器提供了一个高级的 HTTP 服务器 API。
  • com.sun.net.httpserver.spi:这个包提供了一个可插入的服务提供者 API,用于安装 HTTP 服务器替换实现。

com.sun.net.httpserver包包含一个HttpHandler接口,在创建自己的 HTTP 服务器时,必须实现这个接口来处理 HTTP 请求-响应交换。这个包也包含十七个类;四个最重要的等级在表 11-2 中有描述。

images

实现您自己的轻量级 HTTP 服务器包括三个任务:

  1. 创建服务器。抽象的HttpServer类提供了一个HttpServer create(InetSocketAddress addr, int backlog)类方法来创建一个处理 HTTP 协议的服务器。这个方法的addr参数指定了一个java.net.InetSocketAddress对象,它包含服务器监听套接字的 IP 地址和端口号。backlog参数指定在等待服务器接受时可以排队的最大 TCP 连接数;小于或等于零的值会导致使用系统默认值。或者,您可以将null传递给addr或者调用HttpServerHttpServer create()类方法来创建一个不绑定到地址/端口的服务器。如果您选择这种方法,您将需要调用HttpServervoid bind(InetSocketAddress addr, int backlog)方法,然后才能使用服务器。
  2. 创建一个上下文。创建服务器之后,您需要创建至少一个上下文(抽象HttpContext类的子类的实例),它将根 URI 路径映射到HTTPHandler的实现。上下文帮助您组织服务器运行的应用(通过 HTTP 处理程序)。(HttpServer Java 文档显示了传入的请求 URIs 如何映射到HttpContext路径。)您通过调用HttpServerHttpContext createContext(String path, HttpHandler handler)方法创建一个上下文,其中path指定根 URI 路径,而handler指定处理所有指向该路径的请求的HttpHandler实现。如果您愿意,可以在不指定初始处理程序的情况下调用HttpContext createContext(String path)。稍后您将通过调用HttpContextvoid setHandler(HttpHandler h)方法来指定处理程序。
  3. 启动服务器。在创建了服务器和至少一个上下文(包括合适的处理程序)之后,最后的任务是启动服务器。通过调用HttpServervoid start()方法来完成这个任务。

我已经创建了一个最小的 HTTP 服务器应用来演示所有这三个任务。这个应用的源代码出现在清单 11-1 中。

清单 11-1。一个最小的 HTTP 服务器应用

import java.io.IOException;
import java.io.OutputStream;

import java.net.InetSocketAddress;

import java.util.List;
import java.util.Map;
import java.util.Set;

import com.sun.net.httpserver.Headers;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;

class MinimalHTTPServer
{
   public static void main(String[] args) throws IOException
   {
      HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0);
      server.createContext("/echo", new Handler());
      server.start();

   }
}
class Handler implements HttpHandler
{
   @Override
   public void handle(HttpExchange xchg) throws IOException
   {
      Headers headers = xchg.getRequestHeaders();
      Set<Map.Entry<String, List<String>>> entries = headers.entrySet();
      StringBuffer response = new StringBuffer();
      for (Map.Entry<String, List<String>> entry: entries)
         response.append(entry.toString()+"\n");
      xchg.sendResponseHeaders(200, response.length());
      OutputStream os = xchg.getResponseBody();
      os.write(response.toString().getBytes());
      os.close();
   }
}

该处理程序演示了以下HttpExchange抽象方法:

  • 返回 HTTP 请求头的不可变映射。
  • void sendResponseHeaders(int rCode, long responseLength)开始使用当前一组响应头和rCode的数字代码向客户端发回响应;200 表示成功。
  • OutputStream getResponseBody()返回响应体输出到的输出流。这个方法必须在调用sendResponseHeaders()之后调用。

总的来说,这些方法用于将传入请求的标头回显到客户端。图 11-5 显示了发送到服务器后的这些头。不要忘记在echo之前放置任何路径项都会导致 404 Not Found 页面。

images

图 11-5。将传入请求的头回显给客户端。

在调用start()之前,可以指定一个java.util.concurrent.Executor实例(见第六章)处理所有 HTTP 请求。这个任务是通过调用HttpServervoid setExecutor(Executor executor)方法完成的。也可以调用Executor getExecutor()返回当前执行人(没有设置执行人时返回值为 null)。如果在启动服务器之前没有调用setExecutor(),或者如果将null传递给这个方法,那么将使用基于start()创建的线程的默认实现。

您可以通过调用HttpServervoid stop(int delay)方法来停止一个已启动的服务器。此方法关闭侦听套接字,并防止处理任何排队的交换。然后,它会一直阻塞,直到所有当前的交换处理程序都已完成,或者经过了delay秒(以先到者为准)。当delay小于零时,抛出java.lang.IllegalArgumentException类的一个实例。接着,所有打开的 TCP 连接都被关闭,由start()方法创建的线程结束。停止的HttpServer不能重新启动。

本章的大多数例子依赖于默认的轻量级 HTTP 服务器,它是在你调用javax.xml.ws.EndPoint类的publish()方法时创建的。但是,在本章的后面,我还将向您展示如何创建和安装一个定制的轻量级 HTTP 服务器来执行身份验证。

使用基于 SOAP 的 Web 服务

JAX-WS 支持基于 SOAP 的 web 服务。本节首先向您展示如何创建和访问您自己的基于 SOAP 的温度转换 web 服务,如何通过默认的轻量级 HTTP 服务器在本地发布该 web 服务,以及如何通过简单的客户端访问该服务。然后向您展示如何访问斯隆数字巡天的基于 SOAP 的图像剪切 web 服务来获取天文图像。

创建和访问温度转换 Web 服务

我将温度转换 web 服务命名为 TempVerter,它由一对将华氏温度转换为摄氏温度的函数组成,反之亦然。尽管这个例子可以被设计成一个单独的 Java 类,但是我选择遵循最佳实践,将其设计成一个 Java 接口和一个 Java 类。清单 11-2 展示了 web 服务的TempVerter接口。

清单 11-2。 TempVerter 的服务端点接口

package ca.tutortutor.tv;

import javax.jws.WebMethod;
import javax.jws.WebService;

@WebService
public interface TempVerter
{
   @WebMethod double c2f(double degrees);
   @WebMethod double f2c(double degrees);
}

TempVerter描述了一个服务端点接口(SEI) ,这是一个 Java 接口,以抽象 Java 方法的形式公开了 web 服务接口的操作。客户端通过它们的 sei 与基于 SOAP 的 web 服务通信。

通过@WebService注释将TempVerter声明为 SEI。当一个 Java 接口或类被注释为@WebService时,其参数、返回值和声明的异常的所有public方法都遵循 JAX-RPC 1.1 规范 ( [download.oracle.com/otndocs/jcp/jax_rpc-1_1-mrel-oth-JSpec/](http://download.oracle.com/otndocs/jcp/jax_rpc-1_1-mrel-oth-JSpec/))的第五部分中定义的规则来描述 web 服务操作。因为只有public方法可以在接口中声明,所以在声明c2f()f2c()时不需要public保留字。这些方法是隐式的public

每个c2f()f2c()也被标注为@WebMethod。虽然@WebMethod在这个例子中并不重要,但它的存在强调了一个事实,即带注释的方法公开了一个 web 服务操作。

清单 11-3 展示了 web 服务的TempVerterImpl类。

清单 11-3。 TempVerter 的服务实现 Bean

package ca.tutortutor.tv;

import javax.jws.WebService;

@WebService(endpointInterface = "ca.tutortutor.tv.tempverter")
public class TempVerterImpl implements TempVerter
{
   public double c2f(double degrees)
   {
      return degrees*9.0/5.0+32;
   }
   public double f2c(double degrees)
   {
      return (degrees-32)*5.0/9.0;
   }
}

TempVerterImpl描述了一个服务实现 Bean (SIB) ,它提供了 SEI 的一个实现。这个类通过@WebService(endpointInterface = "ca.tutortutor.tv.tempverter")注释被声明为 SIB。endpointInterface元素将这个 SIB 连接到它的 SEI,这对于在运行后面介绍的客户端应用时避免未定义的端口类型错误是必要的。

条款不是绝对必要的。如果这个子句不存在,那么TempVerter接口将被忽略(并且是多余的)。然而,保留implements TempVerter是个好主意,这样编译器可以验证 SEI 的方法已经在 SIB 中实现了。

SIB 的方法头没有注释@WebMethod,因为这个注释通常用在 SEI 的上下文中。然而,如果您要向 SIB 添加一个public方法(它符合 JAX-RPC 1.1 规范第五部分中的规则),并且如果这个方法不公开 web 服务操作,您将注释方法头@WebMethod(exclude = true)。通过将true分配给@WebMethodexclude元素,可以防止该方法与操作相关联。

此 web 服务已准备好发布,以便可以从客户端访问它。清单 11-4 展示了一个TempVerterPublisher应用,它在默认的轻量级 HTTP 服务器的上下文中完成这个任务。

清单 11-4。发布时间

import javax.xml.ws.Endpoint;

import ca.tutortutor.tv.TempVerterImpl;

class TempVerterPublisher
{
   public static void main(String[] args)

   {
      Endpoint.publish("http://localhost:9901/tempverter",
                       new TempVerterImpl());
   }
}

发布 web 服务包括对EndPoint类的Endpoint publish(String address, Object implementor)类方法进行一次调用。address参数标识分配给 web 服务的 URI。我选择通过指定localhost(相当于 IP 地址 127.0.0.1)和端口号 9901(这是最有可能的)在本地主机上发布这个 web 服务。另外,我任意选择了/TempVerter作为发布路径。implementor参数标识 TempVerter 的 SIB 实例。

publish()方法在给定的address为指定的implementor对象创建并发布一个端点,并使用implementor的注释创建 WSDL 和 XML 模式文档。它导致 JAX-WS 实现基于一些默认配置创建和配置必要的服务器基础设施。此外,此方法会导致应用无限期运行。(在 Windows 机器上,同时按 Ctrl 和 C 键终止应用。)

假设当前目录包含TempVerterPublisher.java和一个ca子目录(包含一个tutortutor子目录,包含一个tv子目录,包含TempVerter.javaTempVerterImpl.java,执行javac TempVerterPublisher.java来编译这个源文件连同清单 11-2 和 11-3 。

images 提示javac编译器工具提供了一个-d选项,可以用来指定放置生成的类文件的目录。这样,你就不会混淆源文件和类文件。

如果源代码编译成功,执行java TempVerterPublisher来运行这个应用。您应该看不到任何消息,应用也不会返回到命令提示符。

您可以使用 web 浏览器来测试这个 web 服务并访问它的 WSDL 文档。启动你喜欢的网页浏览器,在其地址栏输入localhost:9901/temp verter。图 11-6 显示了在 Mozilla Firefox 网络浏览器中生成的网页。

images

图 11-6。 TempVerter 的网页提供了已发布网络服务的详细信息。

图 11-6 显示了 web 服务端点的合格服务和端口名。(注意,包名被颠倒了——从ca.tutortutor.tv变成了tv.tutortutor.ca)。客户端使用这些名称来访问服务。

图 11-6 还显示了 web 服务的地址 URI,web 服务的 WSDL 文档的位置(web 服务 URI 的后缀是?wsdl查询字符串),以及 web 服务实现类的包限定名。WSDL 文档的位置显示为一个链接,您可以点击该链接查看该文档——参见清单 11-5 。

清单 11-5。滕弗特尔的 WSDL 文件

<?xml version="1.0" encoding="UTF-8"?>
<definitions targetNamespace="http://tv.tutortutor.ca/" name="TempVerterImplService">
   <types>
      <xsd:schema>
         <xsd:import namespace="http://tv.tutortutor.ca/" schemaLocation="http://localhost:9901/TempVerter?xsd=1"/>
      </xsd:schema>
   </types>
   <message name="c2f">
      <part name="parameters" element="tns:c2f"/>
   </message>
   <message name="c2fResponse">
      <part name="parameters" element="tns:c2fResponse"/>
   </message>
   <message name="f2c">
      <part name="parameters" element="tns:f2c"/>
   </message>

   <message name="f2cResponse">
      <part name="parameters" element="tns:f2cResponse"/>
   </message>
   <portType name="TempVerter">
      <operation name="c2f">
         <input wsam:Action="http://tv.tutortutor.ca/TempVerter/c2fRequest"![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
message="tns:c2f"/>
         <output wsam:Action="http://tv.tutortutor.ca/TempVerter/c2fResponse"
![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
message="tns:c2fResponse"/>
      </operation>
      <operation name="f2c">
         <input wsam:Action="http://tv.tutortutor.ca/TempVerter/f2cRequest"![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
message="tns:f2c"/>
         <output wsam:Action="http://tv.tutortutor.ca/TempVerter/f2cResponse"![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
message="tns:f2cResponse"/>
      </operation>
   </portType>
   <binding name="TempVerterImplPortBinding" type="tns:TempVerter">
      <soap:binding transport="http://schemas.xmlsoap.org/soap/http" style="document"/>
      <operation name="c2f">
         <soap:operation soapAction=""/>
         <input>
            <soap:body use="literal"/>
         </input>
         <output>
            <soap:body use="literal"/>
         </output>
      </operation>
      <operation name="f2c">
         <soap:operation soapAction=""/>
         <input>
            <soap:body use="literal"/>
         </input>
         <output>
            <soap:body use="literal"/>
         </output>
      </operation>
   </binding>
   <service name="TempVerterImplService">
      <port name="TempVerterImplPort" binding="tns:TempVerterImplPortBinding">
         <soap:address location="http://localhost:9901/TempVerter"/>
      </port>
   </service>
</definitions>

WSDL 文档是一个带有根元素的 XML 文档,这使得 WSDL 文档只不过是一组定义。属性为 WSDL 文档中所有用户定义的元素创建一个名称空间(比如通过具有此名称的message元素定义的c2f元素)。这个名称空间用于区分当前 WSDL 文档的用户定义元素和导入的 WSDL 文档的用户定义元素,后者通过 WSDL 的import元素来标识。以类似的方式,出现在基于 XML 模式的文件的schema元素上的targetNamespace属性为其用户定义的简单类型元素、属性元素和复杂类型元素创建一个名称空间。

name属性标识 web 服务,仅用于记录服务。

images 注意生成的<definitions>标签不完整。一个完整的标记应该包括默认的名称空间,以及前缀soaptnswsamxsd的名称空间,如下所示:<definitions name="TempVerterImplService" targetNamespace="http://tv.tutortutor.ca/" xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/" xmlns:tns="http://tv.tutortutor.ca/" xmlns:wsam="http://www.w3.org/2007/05/addressing/metadata" xmlns:xsd="http://www.w3.org/2001/XMLSchema">。看来 JAX-WS 做了假设。

嵌套在definitions中的是typesmessageportTypebindingservice元素:

  • types表示数据类型系统下用户自定义的数据类型(用于message元素的上下文中)。尽管可以使用任何类型定义语言,但 XML Schema 是由基本概要 1.0 中的 WS-I 规定的。types可以包含零个或多个schema元素。这个例子有一个单独的schema元素,它导入一个外部模式。types元素是可选的。当服务只使用内置于简单类型(如字符串和整数)的 XML 模式时,它不存在。
  • message定义一个单向请求或响应消息(概念上是一个函数调用或调用响应),它可能由一个或多个部分组成(概念上等同于函数参数或返回值)。每个部分由一个part元素描述,元素的name属性标识一个参数/返回值元素。element属性标识另一个元素(在别处定义),其值被传递给该参数或提供响应值。可以指定零个或多个part元素,以及零个或多个message元素。
  • portType通过操作描述 web 服务接口。每个operation元素包含基于 MEP 的input和/或output元素。清单 11-5 包含了这两个元素。(当有output元素时,可以指定用于传递错误信息的fault元素。)在 WS-Addressing 的上下文中,wsam:Action属性与消息路由一起使用—参见[en.wikipedia.org/wiki/WS-Addressing](http://en.wikipedia.org/wiki/WS-Addressing)message属性标识通过其name属性描述消息的message元素(还提供描述参数和返回值的part元素)。operation元素是可选的;必须至少指定一个portType元素。
  • binding提供了一个portType操作(如c2ff2c)如何通过线路传输的细节。这个元素的type属性标识了文档前面定义的portType元素。嵌套的soap:binding元素表示正在使用 SOAP 1.1 绑定。它的transport属性的 URI 值将 HTTP 标识为传输协议(HTTP 上的 SOAP),它的style属性将document标识为默认服务风格。每个operation元素由soap:operationinputoutput元素组成。soap:operation元素是一个 SOAP 扩展元素,它在操作级别提供额外的绑定信息。服务器(如防火墙)可以使用SOAPAction属性的 URI 值(如果存在)来过滤通过 HTTP 发送的 SOAP 请求消息。inputoutput元素包含soap:body元素,这些元素的use属性指示消息部分如何出现在 SOAP 的Body元素中——我将在本章后面介绍 SOAP 的概述。literal值意味着这些部分按字面意思出现,而不是被编码。可以指定多个binding元素。
  • service根据暴露绑定的嵌套的port元素定义端点的集合——一个port元素的binding属性标识一个binding元素。此外,port元素标识服务的地址;因为我们正在处理一个 SOAP 服务,port包含了一个soap:address元素,它的location属性指定了这个地址。

typesmessageportType元素是 web 服务接口的抽象定义。它们构成了 web 服务和应用之间的接口。bindingservice元素提供了这个接口如何映射到通过网络传输的消息的具体细节。JAX-WS 代表应用处理这些细节。

样式和用途

soap:binding元素的style属性通过指示操作是面向文档的(消息包含文档)——值为document——还是面向 RPC 的(消息包含参数和返回值)——值为rpc,来影响如何构建 SOAP 消息的Body元素。我将在本章的后面讨论 SOAP 消息架构。

soap:body元素的use属性表示 WSDL 文档的message元素的part子元素是否定义了消息的具体模式——值为literal—或者通过某种编码规则进行编码——值为encoded

use被设置为literal时,每个part元素使用elementtype属性引用一个具体的模式定义。对于element,引用的元素将直接出现在 SOAP 消息的Body元素下(对于document样式绑定),或者出现在以消息部分命名的访问器元素下(对于rpc样式绑定)。对于type,被引用的类型成为封闭元素的模式类型(对于document样式为Body,对于rpc样式为部件访问器元素)。

use被设置为encoded时,每个part元素使用type属性引用一个抽象类型。这些抽象类型通过应用 SOAP 消息的encodingStyle属性指定的编码来产生具体的消息。

关于styleuse属性的更多信息,请查看“我应该使用哪种风格的 WSDL?”([www.ibm.com/developerworks/webservices/library/ws-whichwsdl/](http://www.ibm.com/developerworks/webservices/library/ws-whichwsdl/))。

types元素的schema元素标识存储每个操作的返回和参数类型的模式的位置。xsd:import标签的schemaLocation属性将这个位置标识为[localhost:9901/TempVerter?xsd=1](http://localhost:9901/TempVerter?xsd=1)。当您将浏览器指向这个位置时,您会看到清单 11-6 中的。

清单 11-6。WSDL 文档引用的 XML 模式文档

<xs:schema version="1.0" targetNamespace="http://tv.tutortutor.ca/">
   <xs:element name="c2f" type="tns:c2f"/>
   <xs:element name="c2fResponse" type="tns:c2fResponse"/>
   <xs:element name="f2c" type="tns:f2c"/>
   <xs:element name="f2cResponse" type="tns:f2cResponse"/>
   <xs:complexType name="f2c">
      <xs:sequence>
         <xs:element name="arg0" type="xs:double"/>
      </xs:sequence>
   </xs:complexType>
   <xs:complexType name="f2cResponse">
      <xs:sequence>
         <xs:element name="return" type="xs:double"/>
      </xs:sequence>
   </xs:complexType>
   <xs:complexType name="c2f">
      <xs:sequence>
         <xs:element name="arg0" type="xs:double"/>
      </xs:sequence>
   </xs:complexType>
   <xs:complexType name="c2fResponse">
      <xs:sequence>
         <xs:element name="return" type="xs:double"/>
      </xs:sequence>
   </xs:complexType>
</xs:schema>

你可能想参考第十章,重温一下 XML 模式文档是如何形成的。完成后,查看清单 11-7 的TempVerterClient.java源代码,它向您展示了客户机如何访问 TempVerter web 服务。

清单 11-7。用于访问 TempVerter web 服务的客户端

import java.net.URL;

import javax.xml.namespace.QName;

import javax.xml.ws.Service;

import ca.tutortutor.tv.TempVerter;

class TempVerterClient

{
   public static void main(String[] args) throws Exception
   {
      URL url = new URL("http://localhost:9901/TempVerter?wsdl");
      QName qname = new QName("http://tv.tutortutor.ca/",
                              "TempVerterImplService");
      Service service = Service.create(url, qname);
      qname = new QName("http://tv.tutortutor.ca/", "TempVerterImplPort");
      TempVerter tv = service.getPort(qname, TempVerter.class);
//      TempVerter tv = service.getPort(TempVerter.class);
      System.out.println(tv.c2f(37.0));
      System.out.println(tv.f2c(212.0));
   }
}

TempVerterClient首先创建一个java.net.URL实例来标识 web 服务的 WSDL 文件。然后它创建一个javax.xml.namespace.QName实例来标识端点的合格服务名(参见图 11-6 )。这些实例被传递给javax.xml.ws.Service类的Service create(URL wsdlDocumentLocation, QName serviceName)类方法以返回一个Service实例,该实例提供了 web 服务的客户端视图。

然后在Service实例上调用ServiceT getPort(QName portName, Class<T> serviceEndpointInterface)方法,以返回一个代理,用于通过 web 服务的端点与 web 服务进行通信。传递给portName的限定名标识了端点的限定端口名(参见图 11-6 ),它标识了要访问其操作的 web 服务接口——本例中只有一个接口。传递给serviceEndpointInterfacejava.lang.Class实例标识了TempVerter SEI。这个方法返回一个代理对象,它的类实现了TempVerter,或者在出错时抛出javax.xml.ws.WebServiceException(比如在TempVerterImpl SIB 的@WebService注释中没有指定endpointInterface,调用ServiceT getPort(Class<T> serviceEndpointInterface)方法,该方法使用endpointInterface来访问 SEI)。

假设getPort()成功,返回的对象用于调用c2f()f2c()方法,参数分别代表以摄氏度表示的体温和以华氏度表示的水的沸点。

编译这个类(通过javac TempVerterClient.java,假设当前目录包含这个源文件和一个ca子目录,包含一个tutortutor子目录,包含一个tv子目录,包含清单 11-2 的TempVerter.java源文件)。如果编译成功,执行java TempVerterClient来运行这个应用,它将生成如下输出:

98.6
100.0

因为清单 11-5 的中的 WSDL 文档和清单 11-6 的中的 XML 模式文档包含了足够的信息让客户端与 web 服务进行通信,所以您也可以使用wsimport工具从这个文档中生成客户端支持代码,以便于创建客户端。在 TempVerter 的上下文中,您可以按如下方式使用该工具:

wsimport -keep –p client http://localhost:9901/TempVerter?wsdl

wsimport输出“解析 WSDL…”、“生成代码…”和“编译代码…”消息;并生成客户机访问该 web 服务所需的类文件。-keep选项使wsimport也保存这些类文件的源代码,这有助于我们了解客户端如何访问 web 服务,并使添加客户端处理程序来拦截消息成为可能(在本章后面讨论)。

-p选项标识存储生成的源文件和/或类文件的包目录。你可以指定任何有意义的名字(比如client),wsimport会用这个名字创建一个包目录,并在下面存储这个包目录结构。

images 注意如果不指定-p并且当前目录包含 TempVerter 的包目录结构,清单 11-2 的TempVerter接口源代码(和类文件)将被生成的TempVerter.java源文件(和类文件)的内容覆盖。

除了类文件,wsimport还在client目录中存储了TempVerter.javaTempVerterImplService.java和其他源文件。前一个源文件的 Java 接口声明了与清单 11-2 的TempVerter SEI 接口相同的方法,但是用c2Ff2C方法名代替了c2ff2c,以符合 JAXB 命名约定,其中方法名中每个后续单词的第一个字母都是大写的。

后一个文件的类出现在清单 11-8 中,它提供了一个无参数构造函数来实例化这个类,以及一个getTempVerterImplPort()方法来返回生成的TempVerter接口的一个实例;客户端在这个实例上执行 web 服务的操作。

清单 11-8。用于访问 TempVerter web 服务的清理后的服务实现类

package client;

import java.net.MalformedURLException;
import java.net.URL;

import javax.xml.namespace.QName;

import javax.xml.ws.Service;
import javax.xml.ws.WebEndpoint;
import javax.xml.ws.WebServiceClient;
import javax.xml.ws.WebServiceException;
import javax.xml.ws.WebServiceFeature;

/**
 * This class was generated by the JAX-WS RI.
 * JAX-WS RI 2.2.4-b01
 * Generated source version: 2.2
 *
 */
@WebServiceClient(name = "TempVerterImplService",
                  targetNamespace = "http://tv.tutortutor.ca/",
                  wsdlLocation = "http://localhost:9901/TempVerter?wsdl")
public class TempVerterImplService extends Service
{
   private final static URL TEMPVERTERIMPLSERVICE_WSDL_LOCATION;
   private final static WebServiceException TEMPVERTERIMPLSERVICE_EXCEPTION;
   private final static QName TEMPVERTERIMPLSERVICE_QNAME =
      new QName("http://tv.tutortutor.ca/", "TempVerterImplService");

   static
   {
      URL url = null;
      WebServiceException e = null;
      try
      {
         url = new URL("http://localhost:9901/TempVerter?wsdl");
      }
      catch (MalformedURLException ex)
      {
         e = new WebServiceException(ex);
      }
      TEMPVERTERIMPLSERVICE_WSDL_LOCATION = url;
      TEMPVERTERIMPLSERVICE_EXCEPTION = e;
   }
**   public TempVerterImplService()**
   {
      super(__getWsdlLocation(), TEMPVERTERIMPLSERVICE_QNAME);
   }
   public TempVerterImplService(WebServiceFeature... features)
   {
      super(__getWsdlLocation(), TEMPVERTERIMPLSERVICE_QNAME, features);
   }
   public TempVerterImplService(URL wsdlLocation)
   {
      super(wsdlLocation, TEMPVERTERIMPLSERVICE_QNAME);
   }
   public TempVerterImplService(URL wsdlLocation, WebServiceFeature... features)
   {
      super(wsdlLocation, TEMPVERTERIMPLSERVICE_QNAME, features);
   }
   public TempVerterImplService(URL wsdlLocation, QName serviceName)
   {
      super(wsdlLocation, serviceName);
   }
   public TempVerterImplService(URL wsdlLocation, QName serviceName,
                                WebServiceFeature... features)
   {
      super(wsdlLocation, serviceName, features);
   }
   /**
    *
    * @return
    *     returns TempVerter
    */
   @WebEndpoint(name = "TempVerterImplPort")
   **public TempVerter getTempVerterImplPort()**
   {
      return super.getPort(new QName("http://tv.tutortutor.ca/",
                                     "TempVerterImplPort"), TempVerter.class);
   }
   /**

    *
    * @param features
    *     A list of {@link javax.xml.ws.WebServiceFeature} to configure on the
    *     proxy. Supported features not in the <code>features</code> parameter
    *     will have their default values.
    * @return
    *     returns TempVerter
    */
   @WebEndpoint(name = "TempVerterImplPort")
   public TempVerter getTempVerterImplPort(WebServiceFeature... features)
   {
      return super.getPort(new QName("http://tv.tutortutor.ca/",
                           "TempVerterImplPort"), TempVerter.class, features);
   }
   private static URL __getWsdlLocation()
   {
      if (TEMPVERTERIMPLSERVICE_EXCEPTION!= null)
      {
         throw TEMPVERTERIMPLSERVICE_EXCEPTION;
      }
      return TEMPVERTERIMPLSERVICE_WSDL_LOCATION;
   }
}

TempVerterImplService扩展了Service类来提供 web 服务的客户端视图。有两点需要注意:

  • noargument 构造函数相当于清单 11-7 的Service.create()方法调用。
  • getTempVerterImplPort()相当于清单 11-7 中的getPort()方法调用。

清单 11-9 展示了一个TempVerterClient类的源代码,该类演示了客户端如何使用TempVerterTempVerterImplService来访问 web 服务。

清单 11-9。访问 TempVerter web 服务的简化客户端

import client.TempVerter;
import client.TempVerterImplService;

class TempVerterClient
{
   public static void main(String[] args) throws Exception
   {
      **TempVerterImplService tvis = new TempVerterImplService();**
      **TempVerter tv = tvis.getTempVerterImplPort();**
      System.out.println(tv.c2F(37.0));
      System.out.println(tv.f2C(212.0));
   }
}

假设 web 服务正在运行,并且当前目录包含TempVerterClient.javaclient子目录,执行javac TempVerterClient.java到来编译这个源代码。然后执行java TempVerterClient来运行这个应用。如果一切顺利,您应该观察到以下输出:

98.6
100.0

访问图像剪切网络服务

虽然您可以创建和访问自己的基于 SOAP 的 web 服务,但是您可能希望访问其他人创建的基于 SOAP 的 web 服务。例如,斯隆数字巡天([www.sdss.org](http://www.sdss.org))通过其图像剪切网络服务从其图像档案中提供天文图像。

[casjobs.sdss.org/ImgCutoutDR5/ImgCutout.asmx?wsdl](http://casjobs.sdss.org/ImgCutoutDR5/ImgCutout.asmx?wsdl)的 WSDL 文档中描述了图像剪切的操作。例如,这个 WSDL 文档标识了一个名为GetJpeg的操作,用于返回一个夜空区域的 JPEG 图像,该区域根据右方位角(参见[en.wikipedia.org/wiki/Right_ascension](http://en.wikipedia.org/wiki/Right_ascension))和赤纬角(参见[en.wikipedia.org/wiki/Declination](http://en.wikipedia.org/wiki/Declination))度数值来定位。

在编写允许您访问这个 web 服务以获取(然后显示)任意图像的 Java 应用之前,您需要创建允许这个应用与 web 服务交互的工件(以 Java 类的形式)。您可以通过执行下面的wsimport命令行来生成这些工件:

wsimport -keep http://casjobs.sdss.org/ImgCutoutDR5/ImgCutout.asmx?wsdl

wsimport在当前目录下创建一个org目录。org包含一个sdss子目录,该子目录包含一个skyserver子目录,该子目录存储生成的类文件。此外,skyserver存储它们的源文件(多亏了-keep选项)。

生成的ImgCutout.java源文件揭示了一个无参数的ImgCutout构造函数和一个ImgCutoutSoap getImgCutoutSoap()方法。此外,ImgCutoutSoap声明了一个对应于GetJpeg操作的public byte[] getJpeg(double ra, double dec, double scale, int width, int height, String opt)方法。您的应用通过这个构造函数和这些方法与图像剪切进行交互。

getJpeg()方法的参数描述如下:

  • radec根据赤经和赤纬值指定图像的中心坐标,每个值以度表示。
  • scale以每像素弧秒为单位指定缩放值。一弧秒等于圆的 1/1296000。
  • widthheight标识返回图像的尺寸。
  • opt标识用于绘制图像的字符代码序列;例如,G(在图像上绘制网格)、L(给图像加标签)、以及I(反转图像)。

getJpeg()方法以字节数组的形式返回图像。它从不返回空引用。当发生错误时,方法会传回呈现错误讯息的影像。

有了这些信息,接下来您需要弄清楚如何调用getJpeg()。以下步骤完成了这项任务:

  1. org.sdss.skyserver包中导入ImgCutoutImgCutoutSoap
  2. 实例化ImgCutout
  3. ImgCutout实例上调用getImgCutoutSoap()
  4. 在返回的ImgCutoutSoap实例上调用getJpeg()

我创建了一个SkyView应用来演示这些任务。这个应用提供了一个基于 Swing 的用户界面,用于输入getJpeg()所需的值,并显示结果图像。清单 11-10 展示了这个应用的源代码。

清单 11-10。用于访问图像剪切 web 服务的客户端

import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.EventQueue;
import java.awt.FlowLayout;
import java.awt.GridLayout;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import javax.swing.BorderFactory;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JTextField;

import org.sdss.skyserver.ImgCutout;
import org.sdss.skyserver.ImgCutoutSoap;

class SkyView extends JFrame
{
   final static int IMAGE_WIDTH = 300;
   final static int IMAGE_HEIGHT = 300;
   static ImgCutoutSoap imgcutoutsoap;
   SkyView()
   {
      super("SkyView");
      setDefaultCloseOperation(EXIT_ON_CLOSE);
      setContentPane(createContentPane());
      pack();
      setResizable(false);
      setVisible(true);
   }
   JPanel createContentPane()
   {
      JPanel pane = new JPanel(new BorderLayout(10, 10));
      pane.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10));
      final JLabel lblImage = new JLabel("", JLabel.CENTER);
      lblImage.setPreferredSize(new Dimension(IMAGE_WIDTH+9,
                                                IMAGE_HEIGHT+9));

      lblImage.setBorder(BorderFactory.createEtchedBorder());
      pane.add(new JPanel() {{ add(lblImage); }}, BorderLayout.NORTH);
      JPanel form = new JPanel(new GridLayout(4, 1));
      final JLabel lblRA = new JLabel("Right ascension:");
      int width = lblRA.getPreferredSize().width+20;
      int height = lblRA.getPreferredSize().height;
      lblRA.setPreferredSize(new Dimension(width, height));
      lblRA.setDisplayedMnemonic('R');
      final JTextField txtRA = new JTextField(15);
      lblRA.setLabelFor(txtRA);
      form.add(new JPanel()
               {{
                   add(lblRA); add(txtRA);
                   setLayout(new FlowLayout(FlowLayout.CENTER, 0, 5));
               }});
      final JLabel lblDec = new JLabel("Declination:");
      lblDec.setPreferredSize(new Dimension(width, height));
      lblDec.setDisplayedMnemonic('D');
      final JTextField txtDec = new JTextField(15);
      lblDec.setLabelFor(txtDec);
      form.add(new JPanel()
               {{
                   add(lblDec); add(txtDec);
                   setLayout(new FlowLayout(FlowLayout.CENTER, 0, 5));
               }});
      final JLabel lblScale = new JLabel("Scale:");
      lblScale.setPreferredSize(new Dimension(width, height));
      lblScale.setDisplayedMnemonic('S');
      final JTextField txtScale = new JTextField(15);
      lblScale.setLabelFor(txtScale);
      form.add(new JPanel()
               {{
                   add(lblScale); add(txtScale);
                   setLayout(new FlowLayout(FlowLayout.CENTER, 0, 5));
               }});
      final JLabel lblDO = new JLabel("Drawing options:");
      lblDO.setPreferredSize(new Dimension(width, height));
      lblDO.setDisplayedMnemonic('o');
      final JTextField txtDO = new JTextField(15);
      lblDO.setLabelFor(txtDO);
      form.add(new JPanel()
                {{
                   add(lblDO); add(txtDO);
                   setLayout(new FlowLayout(FlowLayout.CENTER, 0, 5));
                }});

      pane.add(form, BorderLayout.CENTER);
      final JButton btnGP = new JButton("Get Picture");
      ActionListener al;
      al = new ActionListener()
           {
              @Override

              public void actionPerformed(ActionEvent e)
              {
                 try
                 {
                    double ra = Double.parseDouble(txtRA.getText());
                    double dec = Double.parseDouble(txtDec.getText());
                    double scale = Double.parseDouble(txtScale.getText());
                    String dopt = txtDO.getText().trim();
                    byte[] image = imgcutoutsoap.getJpeg(ra, dec, scale,
                                                         IMAGE_WIDTH,
                                                         IMAGE_HEIGHT,
                                                         dopt);
                    lblImage.setIcon(new ImageIcon(image));
                 }
                 catch (Exception exc)
                 {
                    JOptionPane.showMessageDialog(SkyView.this,
                                                  exc.getMessage());
                 }
              }
           };
      btnGP.addActionListener(al);
      pane.add(new JPanel() {{ add(btnGP); }}, BorderLayout.SOUTH);
      return pane;
   }
   public static void main(String[] args)
   {
      ImgCutout imgcutout = new ImgCutout();
      imgcutoutsoap = imgcutout.getImgCutoutSoap();
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         new SkyView();
                      }
                   };
      EventQueue.invokeLater(r);
   }
}

清单 11-10 主要关注的是创建SkyView的用户界面。(第七章解释了在它的构造中使用的类和方法。)像new JPanel () {{ add (lblImage); }}这样的表达式是通过匿名类(参见第三章)子类化javax.swing.JPanel的一个方便的简写,创建子类 panel 的一个实例,(对于这个例子)通过它的对象初始化器将指定的组件添加到 panel,并返回 panel 实例。

假设当前目录包含SkyView.javaorg子目录,调用javac SkyView.java来编译这个应用的源代码。编译之后,调用java SkyView来运行应用。图 11-7 显示了当您在图的文本字段中指定值时,您将会看到的内容。

images

图 11-7。观看新银河星表(NGC) 5792 的图像,这是一个几乎从侧面看到的螺旋星系。这颗明亮的红星位于银河系。

images 在斯隆数字巡天/天空服务器网站([cas.sdss.org/](http://cas.sdss.org/))查看“著名地方”页面([cas.sdss.org/dr6/en/tools/places/](http://cas.sdss.org/dr6/en/tools/places/)),获取各种天文图像的赤经和赤纬值。

使用 RESTful Web 服务

JAX-WS 也支持 RESTful web 服务。本节首先向您展示如何创建和访问您自己的 RESTful 库 web 服务,如何通过默认的轻量级 HTTP 服务器在本地发布这个 web 服务,以及如何通过一个简单的客户端访问这个服务。然后向您展示如何访问 Google 的 RESTful Charts web 服务,以获得与输入的数据值相对应的图表图像。

images Java EE 为 RESTful web 服务(JAX-RS)提供 Java API,通过各种注释简化 RESTful Web 服务的创建。例如,@GET是一个请求方法(HTTP 动词)指示符,对应于类似命名的 HTTP 动词。用这个请求方法指示符注释的 Java 方法处理 HTTP GET请求。查看 Java EE 6 教程第十九章“用 JAX-RS 构建 RESTful Web 服务”(见[download.oracle.com/javaee/6/tutorial/doc/giepu.html](http://download.oracle.com/javaee/6/tutorial/doc/giepu.html))来了解 JAX-RS。

创建和访问图书馆网络服务

我将图书馆 web 服务命名为 library,它由四个 HTTP 操作组成,这些操作处理删除特定图书(通过 ISBN 标识)或所有图书、获取特定图书(通过 ISBN 标识)或所有图书的 ISBN、插入新书或更新现有图书的请求。清单 11-11 展示了 web 服务的Library端点类。

清单 11-11。库的端点类

import java.beans.XMLDecoder;
import java.beans.XMLEncoder;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringReader;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;

import javax.annotation.Resource;

import javax.xml.transform.Source;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;

import javax.xml.transform.dom.DOMResult;

import javax.xml.transform.stream.StreamSource;

import javax.xml.ws.BindingType;
import javax.xml.ws.Endpoint;
import javax.xml.ws.Provider;
import javax.xml.ws.ServiceMode;
import javax.xml.ws.WebServiceContext;
import javax.xml.ws.WebServiceProvider;

import javax.xml.ws.handler.MessageContext;

import javax.xml.ws.http.HTTPBinding;
import javax.xml.ws.http.HTTPException;

import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

import org.w3c.dom.NodeList;

@WebServiceProvider
@ServiceMode(value = javax.xml.ws.Service.Mode.MESSAGE)
@BindingType(value = HTTPBinding.HTTP_BINDING)
class Library implements Provider<Source>
{
   private final static String LIBFILE = "library.ser";
   @Resource
   private WebServiceContext wsContext;
   private Map<String, Book> library;
   Library()
   {
      try
      {
         library = deserialize();
      }
      catch (IOException ioe)
      {
         library = new HashMap<>();
      }
   }
   @Override
   public Source invoke(Source request)
   {
      if (wsContext == null)
         throw new RuntimeException("dependency injection failed on wscontext");
      MessageContext msgContext = wsContext.getMessageContext();
      switch ((String) msgContext.get(MessageContext.HTTP_REQUEST_METHOD))
      {
         case "delete": return doDelete(msgContext);
         case "get"   : return doGet(msgContext);
         case "post"  : return doPost(msgContext, request);
         case "put"   : return doPut(msgContext, request);
         default      : throw new HTTPException(405);

      }
   }
   private Source doDelete(MessageContext msgContext)
   {
      try
      {
         String qs = (String) msgContext.get(MessageContext.QUERY_STRING);
         if (qs == null)
         {
            library.clear();
            serialize();
            StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
            xml.append("<response>all books deleted</response>");
            return new StreamSource(new StringReader(xml.toString()));
         }
         else
         {
            String[] pair = qs.split("=");
            if (!pair[0].equalsIgnoreCase("isbn"))
               throw new HTTPException(400);

            String isbn = pair[1].trim();
            library.remove(isbn);
            serialize();
            StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
            xml.append("<response>book deleted</response>");
            return new StreamSource(new StringReader(xml.toString()));
         }
      }
      catch (IOException ioe)
      {
         throw new HTTPException(500);
      }
   }
   private Source doGet(MessageContext msgContext)
   {
      String qs = (String) msgContext.get(MessageContext.QUERY_STRING);
      if (qs == null)
      {
         Set<String> keys = library.keySet();
         Iterator<String> iter = keys.iterator();
         StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
         xml.append("<isbns>");
         while (iter.hasNext())
            xml.append("<isbn>"+iter.next()+"</isbn>");
         xml.append("</isbns>");
         return new StreamSource(new StringReader(xml.toString()));
      }
      else
      {
         String[] pair = qs.split("=");
         if (!pair[0].equalsIgnoreCase("isbn"))
            throw new HTTPException(400);
         String isbn = pair[1].trim();
         Book book = library.get(isbn);
         if (book == null)
            throw new HTTPException(404);
         StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
         xml.append("<book isbn=\""+book.getisbn()+"\" "+
                    "pubyear=\""+book.getpubyear()+"\">");
         xml.append("<title>"+book.getTitle()+"</title>");
         for (Author author: book.getAuthors())
            xml.append("<author>"+author.getName()+"</author>");
         xml.append("<publisher>"+book.getPublisher()+"</publisher>");
         xml.append("</book>");
         return new StreamSource(new StringReader(xml.toString()));
      }
   }
   private Source doPost(MessageContext msgContext, Source source)
   {
      try
      {
         DOMResult dom = new DOMResult();
         Transformer t = TransformerFactory.newInstance().newTransformer();
         t.transform(source, dom);
         XPathFactory xpf = XPathFactory.newInstance();
         XPath xp = xpf.newXPath();
         NodeList books = (NodeList) xp.evaluate("/book", dom.getNode(),
                                                 XPathConstants.NODESET);
         String isbn = xp.evaluate("@isbn", books.item(0));
         if (library.containsKey(isbn))
            throw new HTTPException(400);
         String pubYear = xp.evaluate("@pubyear", books.item(0));
         String title = xp.evaluate("title", books.item(0)).trim();
         String publisher = xp.evaluate("publisher", books.item(0)).trim();
         NodeList authors = (NodeList) xp.evaluate("author", books.item(0),
                                                   XPathConstants.NODESET);
         List<Author> auths = new ArrayList<>();
         for (int i = 0; i < authors.getLength(); i++)
            auths.add(new Author(authors.item(i).getFirstChild()
                                        .getNodeValue().trim()));
         Book book = new Book(isbn, title, publisher, pubYear, auths);
         library.put(isbn, book);
         serialize();
      }
      catch (IOException | TransformerException e)
      {
         throw new HTTPException(500);
      }
      catch (XPathExpressionException xpee)
      {
         throw new HTTPException(400);
      }
      StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
      xml.append("<response>book inserted</response>");

      return new StreamSource(new StringReader(xml.toString()));
   }
   private Source doPut(MessageContext msgContext, Source source)
   {
      try
      {
         DOMResult dom = new DOMResult();
         Transformer t = TransformerFactory.newInstance().newTransformer();
         t.transform(source, dom);
         XPathFactory xpf = XPathFactory.newInstance();
         XPath xp = xpf.newXPath();
         NodeList books = (NodeList) xp.evaluate("/book", dom.getNode(),
                                                 XPathConstants.NODESET);
         String isbn = xp.evaluate("@isbn", books.item(0));
         if (!library.containsKey(isbn))
            throw new HTTPException(400);
         String pubYear = xp.evaluate("@pubyear", books.item(0));
         String title = xp.evaluate("title", books.item(0)).trim();
         String publisher = xp.evaluate("publisher", books.item(0)).trim();
         NodeList authors = (NodeList) xp.evaluate("author", books.item(0),
                                                   XPathConstants.NODESET);
         List<Author> auths = new ArrayList<>();
         for (int i = 0; i < authors.getLength(); i++)
            auths.add(new Author(authors.item(i).getFirstChild()
                                        .getNodeValue().trim()));
         Book book = new Book(isbn, title, publisher, pubYear, auths);
         library.put(isbn, book);
         serialize();
      }
      catch (IOException | TransformerException e)
      {
         throw new HTTPException(500);
      }
      catch (XPathExpressionException xpee)
      {
         throw new HTTPException(400);
      }
      StringBuilder xml = new StringBuilder("<?xml version=\"1.0\"?>");
      xml.append("<response>book updated</response>");
      return new StreamSource(new StringReader(xml.toString()));
   }
   private Map<String, Book> deserialize() throws IOException
   {
      try (BufferedInputStream bis
             = new BufferedInputStream(new FileInputStream(LIBFILE));
           XMLDecoder xmld = new XMLDecoder(bis))
      {
         @SuppressWarnings("unchecked")
         Map<String, Book> result = (Map<String, Book>) xmld.readObject();
         return result;
      }
   }

   private void serialize() throws IOException
   {
      try (BufferedOutputStream bos
             = new BufferedOutputStream(new FileOutputStream(LIBFILE));
           XMLEncoder xmle = new XMLEncoder(bos))
      {
         xmle.writeObject(library);
      }
   }
   public static void main(String[] args)
   {
      Endpoint.publish("http://localhost:9902/library", new Library());
   }
}

在各种导入语句之后,清单 11-11 给出了Library类,其前缀为@WebServiceProvider@ServiceMode@Binding注释。

@WebServiceProvider指定Library是一个 web 服务端点类,根据它的T invoke(T request)方法实现了javax.xml.ws.Provider<T>接口(需要在 XML 消息级别工作的服务的 SEI 的一个替代方案)。传递给类型参数T的实际类型参数标识请求和响应数据的来源,并且是javax.xml.transform.Sourcejavax.activation.DataSourcejavax.xml.soap.SOAPMessage中的一个。对于 RESTful web 服务提供者,您应该为T指定SourceDataSource

images 注意尽管您可以直接通过 web 服务提供者处理 SOAP 消息,但是通常通过使用@WebService——带注释的 sei 和 sib 来忽略这些消息,如前所述。此外,通过使用 SAAJ API,你可以从 API 的角度处理 SOAP 消息,我将在本章后面介绍它。

当向 RESTful web 服务发出请求时,将使用字节源调用提供者类的invoke()方法,例如POST请求的 XML 文档。invoke()方法以某种适当的方式响应请求,以 XML 格式返回构成服务响应的字节源。当出错时,这个方法抛出一个WebServiceException运行时异常类的实例或者它的一个子类(例如javax.xml.ws.http.HTTPException)。

images 注意@WebService标注的类为每个 web 服务操作公开了一个单独的方法。例如,TempVerter为摄氏到华氏和华氏到摄氏消息公开了c2f()f2c()方法。相比之下,@WebServiceProvider公开了一个单独的invoke()方法来处理所有操作。

@ServiceMode指定Libraryinvoke()方法通过将其value()元素初始化为javax.xml.ws.Service.Mode.MESSAGE来接收整个协议消息(而不是消息有效载荷)。当该注释不存在时,value()默认为javax.xml.ws.Service.Mode.PAYLOAD

images 注意 @ServiceMode在 RESTful web 服务的上下文中是不必要的,在 RESTful web 服务中,协议消息和有效负载是相同的——我在清单 11-11 中包含了这个注释,以引起您的注意。然而,当处理 SOAP 消息(通过实现Provider<SOAPMessage>)并希望处理整个消息而不仅仅是有效负载时,@ServiceMode是必要的。在本章的后面,当我介绍 SAAJ API 的时候,你会学到 SOAP 消息架构。

@BindingType指定Libraryinvoke()方法通过将其value()元素初始化为HTTPBinding.HTTP_BINDING来接收 HTTP 上的任意 XML 消息——默认绑定是 HTTP 上的 SOAP 1.1。与@ServiceMode不同,@BindingType必须在此初始化中指定;否则,当 RESTful 客户机向这个 web 服务提供者发送非 SOAP 请求消息时,您会收到一个运行时异常。

Library首先声明一个LIBFILE常量,它标识存储图书馆中书籍信息的文件的名称。我本可以使用 JDBC 来创建和访问图书馆数据库,但是决定使用一个文件来防止清单 11-11 变得更长。

这个字符串常量被初始化为library.ser,其中ser表示文件存储的是序列化数据。存储的数据是一个包含BookAuthor实例的映射的 XML 编码——我将展示这个映射,讨论它的编码/解码,并很快展示这些类。

LIBFILE常量声明之后是wsContext字段声明,其中wsContext被声明为javax.xml.ws.WebServiceContext类型,并用@Resource进行注释。WebServiceContext是一个接口,它使得 web 服务端点实现类能够访问请求消息的上下文和其他信息。@Resource注释使得这个接口的实现被注入到一个端点实现类中,并且使得这个实现类的一个实例(一个依赖项)被分配给变量。

images 依赖注入是指将一个类插入到另一个类中,并将被插入类的对象插入到一个类实例中。插入的对象被称为依赖关系,因为插入这些对象的类的实例依赖于它们。依赖注入通过将开发人员的任务卸载到依赖注入框架来降低类的复杂性。

wsContext声明之后是library字段声明,其中library被声明为类型Map<String, Book>。该变量将图书存储在地图中,其中图书的 ISBN 作为地图条目的键,图书的信息记录在一个作为地图条目值的Book对象中。

Library接下来声明一个无参数构造函数,它的任务是初始化library。构造函数首先试图通过调用deserialize()方法(稍后解释)将library.ser的内容反序列化为一个java.util.HashMap实例,并将该实例的引用分配给library。如果这个文件不存在,java.io.IOException被抛出,一个空的HashMap实例被创建并分配给library——注意 Java 7 的菱形操作符的使用,以避免必须重新指定映射的java.lang.StringBook实际类型参数。

现在已经声明了invoke()方法。它的第一个任务是通过测试wsContext来确定它是否包含空引用,从而验证依赖注入是否成功。如果是这样的话,依赖注入就失败了,并且用合适的消息创建了一个java.lang.RuntimeException类的实例并抛出。

继续,invoke()调用WebServiceContextMessageContext getMessageContext()方法返回实现javax.xml.ws.handler.MessageContext接口的类的实例。此实例抽象了调用此方法时所服务的请求的消息上下文。

MessageContext扩展了Map<String, Object>,使MessageContext成为一种特殊的地图。该接口声明了与继承的Object get(String key)方法一起使用的各种常量,以获得关于请求的信息。例如,get(MessageContext.HTTP_REQUEST_METHOD)返回一个String对象,标识 RESTful 客户端想要执行的 HTTP 操作;比如POST

此时,您可能希望将字符串的内容转换为大写,并删除任何前导或尾随空格。我不执行这些任务,因为我稍后介绍的客户机不允许指定不完全大写和/或前面/后面有空格的 HTTP 动词。

Java 7 的 switch-on-string 语言特性用于简化调用对应于 HTTP 动词的方法的逻辑。传递给doDelete()doGet()doPost()doPut()助手方法的第一个参数是MessageContext实例(分配给msgContext)。虽然没有被doPost()doPut()使用,但是为了一致性,这个实例被传递给这些方法——将来我可能想从doPost()doPut()访问消息上下文。相比之下,invoke()request参数只传递给doPost()doPut(),这样这些方法就可以访问请求的字节源,它由要插入或更新的图书的 XML 组成。

如果任何其他 HTTP 动词(比如HEAD)应该作为请求方法传递,invoke()通过抛出一个带有 405 响应代码的HTTPException类的实例来响应(不允许请求方法)。

doDelete()方法首先获取查询字符串,该字符串通过 ISBN 标识要删除的图书(如在?isbn=9781430234135中)。它通过调用传递给该方法的msgContext参数上的get(MessageContext.QUERY_STRING)来实现。

如果空引用返回,则没有查询字符串,并且doDelete()通过执行library.clear()删除映射中的所有条目。然后这个方法调用serialize()方法将库映射持久化到library.ser,这样这个 web 服务的下一次调用将找到一个空库。

如果传递了一个查询字符串,它将以 key1 = *value1* & *key2* = *value2* & …的形式返回。doDelete()假设只传递了一个 key = value 对,并将这个对拆分成一个有两个条目的数组。

doDelete()首先验证密钥是isbnISBN之一,还是这些字母的任何其他大/小写混合。当这个键是任何其他字符组合时,doDelete()抛出HTTPException,并给出一个 400 响应代码,表示一个错误的请求。对于单个键来说,这种验证并不重要,但是如果传递了多个键/值对,就需要执行验证来区分键。

提取 ISBN 值后,doDelete()将这个值传递给library.remove(),后者从library地图中删除 ISBN String对象键/ Book对象值条目。然后它调用serialize()将新的映射持久化到library.ser,并创建一个 XML 响应消息发送回客户端。消息作为封装在java.io.StringReader实例中的String对象从invoke()返回,该实例封装在javax.xml.transform.stream.StreamSource对象中。

如果doDelete()遇到问题,它抛出一个HTTPException实例,响应代码 500 表示内部错误。

doGet()方法类似于doDelete()。但是,它通过返回包含所有 ISBN 列表的 XML 文档,或者包含特定 ISBN 的图书信息的 XML 文档,来响应查询字符串的存在与否。

doPost()doPut()方法也有类似的架构。每个方法首先将传递给它的source参数(它标识了POSTPUT请求的 XML 主体)的变量转换成一个javax.xml.transform.dom.DOMResult实例。然后通过 XPath 表达式搜索该实例,首先搜索单个book元素,然后搜索<book>标签的isbnpubyear属性,最后搜索book元素的嵌套titleauthorpublisher元素——可能存在多个author元素。收集的信息用于构造AuthorBook对象,其中Author对象存储在Book对象中。得到的Book对象存储在library映射中,映射被序列化为library.ser,一个合适的 XML 消息被发送到客户端。

除了提供稍微不同的响应消息之外,doPost()doPut()的不同之处还在于图书是否已经记录在地图中(由其 ISBN 决定)。如果调用了doPost(),并且地图中有该书的条目,doPost()抛出HTTPException,响应代码为 400(错误请求)。如果调用了doPut(),而该书的条目不在地图中,doPut()抛出同样的异常。

doPut()方法后面是deserialize()serialize()方法,分别负责反序列化来自library.ser的序列化库映射和序列化这个映射到library.ser。这些方法在java.beans.XMLDecoderjava.beans.XMLEncoder类的帮助下完成它们的任务。根据他们的文档,XMLEncoderXMLDecoder分别被设计为将 JavaBean 组件序列化为基于 XML 的文本表示,并将该表示反序列化为 JavaBean 组件。

JAVABEANS

JavaBeans 是用于创建自包含和可重用组件的 Java 架构,这些组件被称为bean。bean 是从至少遵守以下三个约定的类实例化的:

  • 该类必须包含一个public无参数构造函数。
  • 每个类的属性必须包含一个以getis为前缀的访问器方法(对于布尔属性)和一个以set为前缀的赋值器方法。首字母大写的属性名必须跟在前缀后面。例如,String name;属性声明将包含一个String getName()访问器方法和一个void setName(String name)赋值器方法。
  • 该类的实例必须是可序列化的。

第一个约定允许应用和框架轻松地实例化 bean,第二个约定允许它们自动检查和更新 bean 状态,第三个约定允许它们可靠地将 bean 状态存储到持久性存储(如文件)中,并从持久性存储中恢复 bean 状态。

创建 JavaBeans 是为了让可视化编辑器可以呈现 Swing 组件的调色板(例如,JListJButton),开发人员可以访问这些组件来快速创建图形用户界面。然而,JavaBeans 适用于任何类型的面向组件的编辑器。

JavaBeans 对于激活框架也很有用,激活框架可以确定任意数据的类型,封装对数据的访问,发现数据的可用操作,并实例化适当的 bean 来执行这些操作。

例如,如果一个基于 Java 的浏览器获得了一个 JPEG 图像,JavaBeans 激活框架将使浏览器能够将该数据流识别为一个 JPEG 图像。根据该类型,浏览器可以定位并实例化一个对象来操作或查看该图像。

有关 JavaBeans 的更多信息,请查看 Oracle 在线 Java 教程([download.oracle.com/javase/tutorial/javabeans/TOC.html](http://download.oracle.com/javase/tutorial/javabeans/TOC.html))中的“JavaBeans Trail”。

在为library.ser创建必要的输出流并通过 Java 7 的 try-with-resources 语句实例化XMLEncoder之后(无论是否抛出异常,都要确保适当的资源清理),serialize()调用XMLEncodervoid writeObject(Object o)方法,并将library作为该方法的参数,这样整个地图将被序列化。deserialize()方法为library.ser创建必要的输入流,实例化XMLDecoder,调用这个类的XMLDecoderObject readObject()方法,并将这个方法返回的反序列化对象在强制转换为Map<String, Book>后返回。

最后,清单 11-11 声明了一个main()方法,通过执行Endpoint.publish("http://localhost:9902/library", new Library());在本地主机端口 9902 的路径/library上发布这个 web 服务。

为了完整起见,清单 11-12 给出了Book类,它的 beans 存储了关于单本书的信息。

清单 11-12。图书馆的Book

import java.util.List;

**public** class Book implements java.io.Serializable
{
   private String isbn;
   private String title;
   private String publisher;
   private String pubYear;
   private List<Author> authors;
   **public** Book() {} // Constructor and class must be **public** for instances to
                    // be treated as beans.
   Book(String isbn, String title, String publisher, String pubYear,
        List<Author> authors)
   {
      setISBN(isbn);
      setTitle(title);
      setPublisher(publisher);
      setPubYear(pubYear);
      setAuthors(authors);
   }
   List<Author> getAuthors() { return authors; }
   String getISBN() { return isbn; }
   String getPublisher() { return publisher; }
   String getPubYear() { return pubYear; }
   String getTitle() { return title; }
   void setAuthors(List<Author> authors) { this.authors = authors; }
   void setISBN(String isbn) { this.isbn = isbn; }
   void setPublisher(String publisher) { this.publisher = publisher; }
   void setPubYear(String pubYear) { this.pubYear = pubYear; }
   void setTitle(String title) { this.title = title; }
}

Book依赖于一个Author类,它的 beans 存储了单个作者的名字,这在清单 11-13 中给出。

清单 11-13。图书馆的Author

public class Author implements java.io.Serializable
{
   private String name;
   public Author() {}
   Author(String name) { setName(name); }
   String getName() { return name; }
   void setName(String name) { this.name = name; }
}

现在您已经了解了库 web 服务是如何实现的,您需要一个客户机来测试这个 web 服务。清单 11-14 的LibraryClient.java源代码演示了客户端如何通过java.net.HttpURLConnection类访问图书馆 web 服务。

清单 11-14。访问图书馆网络服务的客户端

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.OutputStreamWriter;

import java.net.HttpURLConnection;
import java.net.URL;

class LibraryClient
{
   final static String LIBURI = "http://localhost:9902/library";
   public static void main(String[] args) throws Exception
   {
      String book1 = "<?xml version=\"1.0\"?>"+
                     "<book isbn=\"0201548550\" pubyear=\"1992\">"+
                     "  <title>"+
                     "    advanced c+"+
                     "  </title>"+
                     "  <author>"+
                     "    james o. coplien"+
                     "  </author>"+
                     "  <publisher>"+
                     "    addison wesley"+
                     "  </publisher>"+
                     "</book>";
      doPost(book1);
      String book2 = "<?xml version=\"1.0\"?>"+
                     "<book isbn=\"9781430210450\" pubyear=\"2008\">"+
                     "  <title>"+
                     "    beginning groovy and grails"+
                     "  </title>"+
                     "  <author>"+
                     "    christopher m. judd"+
                     "  </author>"+

                     "  <author>"+
                     "    joseph faisal nusairat"+
                     "  </author>"+
                     "  <author>"+
                     "    james shingler"+
                     "  </author>"+
                     "  <publisher>"+
                     "    apress"+
                     "  </publisher>"+
                     "</book>";
      doPost(book2);
      doGet(null);
      doGet("0201548550");
      doGet("9781430210450");
      String book1u = "<?xml version=\"1.0\"?>"+
                      "<book isbn=\"0201548550\" pubyear=\"1992\">"+
                      "  <title>"+
                      "    advanced c++"+
                      "  </title>"+
                      "  <author>"+
                      "    james o. coplien"+
                      "  </author>"+
                      "  <publisher>"+
                      "    addison wesley"+
                      "  </publisher>"+
                      "</book>";
      doPut(book1u);
      doGet("0201548550");
      doDelete("0201548550");
      doGet(null);
   }
   static void doDelete(String isbn) throws Exception
   {
      URL url = new URL(LIBURI+((isbn != null) ? "?isbn="+isbn : ""));
      HttpURLConnection httpurlc = (HttpURLConnection) url.openConnection();
      httpurlc.setRequestMethod("delete");
      httpurlc.setDoInput(true);
      InputStreamReader isr;
      isr = new InputStreamReader(httpurlc.getInputStream());
      BufferedReader br = new BufferedReader(isr);
      StringBuilder xml = new StringBuilder();
      String line;
      while ((line = br.readLine()) != null)
         xml.append(line);
      System.out.println(xml);
      System.out.println();
   }
   static void doGet(String isbn) throws Exception
   {
      URL url = new URL(LIBURI+((isbn != null) ? "?isbn="+isbn : ""));
      HttpURLConnection httpurlc = (HttpURLConnection) url.openConnection();
      httpurlc.setRequestMethod("GET");

      httpurlc.setDoInput(true);
      InputStreamReader isr;
      isr = new InputStreamReader(httpurlc.getInputStream());
      BufferedReader br = new BufferedReader(isr);
      StringBuilder xml = new StringBuilder();
      String line;
      while ((line = br.readLine()) != null)
         xml.append(line);
      System.out.println(xml);
      System.out.println();
   }
   static void doPost(String xml) throws Exception
   {
      URL url = new URL(LIBURI);
      HttpURLConnection httpurlc = (HttpURLConnection) url.openConnection();
      httpurlc.setRequestMethod("post");
      httpurlc.setDoOutput(true);
      httpurlc.setDoInput(true);
      httpurlc.setRequestProperty("content-type", "text/xml");
      OutputStream os = httpurlc.getOutputStream();
      OutputStreamWriter osw = new OutputStreamWriter(os, "utf-8");
      osw.write(xml);
      osw.close();
      if (httpurlc.getResponseCode() == 200)
      {
         InputStreamReader isr;
         isr = new InputStreamReader(httpurlc.getInputStream());
         BufferedReader br = new BufferedReader(isr);
         StringBuilder sb = new StringBuilder();
         String line;
         while ((line = br.readLine()) != null)
            sb.append(line);
         System.out.println(sb.toString());
      }
      else
         System.err.println("cannot insert book: "+httpurlc.getResponseCode());
      System.out.println();
   }
   static void doPut(String xml) throws Exception
   {
      URL url = new URL(LIBURI);
      HttpURLConnection httpurlc = (HttpURLConnection) url.openConnection();
      httpurlc.setRequestMethod("put");
      httpurlc.setDoOutput(true);
      httpurlc.setDoInput(true);
      httpurlc.setRequestProperty("content-type", "text/xml");
      OutputStream os = httpurlc.getOutputStream();
      OutputStreamWriter osw = new OutputStreamWriter(os, "utf-8");
      osw.write(xml);
      osw.close();
      if (httpurlc.getResponseCode() == 200)

      {
         InputStreamReader isr;
         isr = new InputStreamReader(httpurlc.getInputStream());
         BufferedReader br = new BufferedReader(isr);
         StringBuilder sb = new StringBuilder();
         String line;
         while ((line = br.readLine()) != null)
            sb.append(line);
         System.out.println(sb.toString());
      }
      else
         System.err.println("cannot update book: "+httpurlc.getResponseCode());
      System.out.println();
   }
}

LibraryClient被分成一个main()方法和四个do前缀的方法,用于执行DELETEGETPOSTPUT操作。main()调用每个do方法发出请求并输出响应。

一个“do”方法首先实例化了URL类;当用非空的isbn参数调用这些方法时,doDelete()doGet()将查询字符串附加到它们的 URI 参数上。然后,该方法调用URLURLConnection openConnection()方法返回应用和URL实例之间的通信链接,作为抽象java.net.URLConnection类的具体子类的实例。这个具体的子类是HttpConnection,因为传递给URL的构造函数的参数中有http://前缀。

然后调用HttpURLConnectionvoid setRequestMethod(String method)来指定 HTTP 动词,它必须以大写形式出现,不能有空格。根据“do方法的不同,要么用true参数调用void setDoInput(boolean doinput),要么用true参数调用void setDoInput(boolean doinput)void setDoOutput(boolean dooutput),以表示需要一个输入流或输入和输出流来与 web 服务通信。

每个doPost()doPut()都需要将Content-Type请求头设置为text/xml,这是通过将这个头和 MIME 类型传递给void setRequestProperty(String key, String value)方法来实现的。忘记将内容类型设置为text/xml会导致 JAX-WS 基础设施以内部错误响应代码进行响应(500)。

doDelete()doGet()从连接的输入流中读取 XML,并将这个 XML 内容输出到标准输出设备。在幕后,JAX-WS 基础设施使封装在StringReader实例中的字符串在输入流上可用,该字符串封装在从invoke()返回的StreamSource实例中。

doPost()doPut()访问连接的输出流,并将它们的 XML 内容输出到流中。在幕后,JAX-WS 使这些内容对invoke()可用,作为实现Source接口的类的实例。假设 web 服务用一个成功代码(200)响应,每个方法从连接的输入流中读取 XML 回复,并将该内容输出到标准输出流。

编译Library.java ( javac Library.java)和LibraryClient.java ( javac LibraryClient.java)。在一个命令窗口(java Library)运行Library,在另一个命令窗口(java LibraryClient)运行LibraryClient。如果一切顺利,LibraryClient应该生成以下输出:

<response>book inserted</response>

<response>book inserted</response>

<isbns><isbn>9781430210450</isbn><isbn>0201548550</isbn></isbns>

<book isbn="0201548550" pubyear="1992"><title>Advanced C+</title><author>James O.![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) Coplien</author><publisher>Addison Wesley</publisher></book>

<book isbn="9781430210450" pubyear="2008"><title>Beginning Groovy and![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) Grails</title><author>Christopher M. Judd</author><author>Joseph Faisal![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) Nusairat</author><author>James Shingler</author><publisher>Apress</publisher></book>

<response>book updated</response>

<book isbn="0201548550" pubyear="1992"><title>Advanced C++</title><author>James O.![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) Coplien</author><publisher>Addison Wesley</publisher></book>

<response>book deleted</response>

<isbns><isbn>9781430210450</isbn></isbns>

第二次运行LibraryClient,您应该观察到第二条<response>book inserted</response>消息已经被替换为cannot insert book: 400。输出此消息是因为library映射已经包含一个条目,其关键字标识 ISBN 9781430210450。

images 注意当您重新运行LibraryClient并观察cannot insert book: 400消息时,您可能还会观察到奇怪的Library输出。具体来说,您可能会注意到一个抛出的异常,它的第一行以日期和时间开始,以com.sun.xml.internal.ws.server.provider.SyncProviderInvokerTube processRequest结尾,第二行由SEVERE: null组成,第三行由javax.xml.ws.http.HTTPException组成。这个奇怪的输出来自于doPost()检测到重新插入一本已经插入的书的尝试,然后将HTTPException抛出到Libraryinvoke()方法,然后从invoke()抛出——从invoke()抛出这个异常是合法的,它被记录为抛出WebServiceException(而HTTPException是这个类的后代)。当我第一次发现这个问题时,我联系了 Oracle(在 Java 7 发布前几天),并被告知提交一份错误报告。我提交了“Bug ID: 7068897 -从 Provider < Source > invoke()方法抛出 HTTPException 时的奇怪错误”,这个 Bug 报告在奇怪地消失之前保留了几天。也许我在 Windows XP Service Pack 3 上运行Library时遇到了异常。然而,这可能是一个真正的 Java bug。

访问谷歌图表网络服务

访问别人的 RESTful web 服务比创建自己的服务更容易,因为你可以忘记 JAX-WS,只需要处理HttpURLConnection来发出请求和检索必要的数据。此外,您并不局限于检索 XML 数据。例如,Google 的 RESTful Charts web 服务([code.google.com/apis/chart/image/docs/making_charts.html](http://code.google.com/apis/chart/image/docs/making_charts.html)),也称为 Chart API,允许您动态创建和返回条形图、饼图和其他类型图表的图像。

谷歌图表是通过[chart.googleapis.com/chart](https://chart.googleapis.com/chart) URI 访问的。您可以向该 URI 追加一个查询字符串,用于标识图表类型、大小、数据、标签和任何其他需要的信息。例如,查询字符串“?cht=p3&chs=450x200&chd=t:60,40&chl=Q1%20(60%)|Q2%20(40%)”描述了以下图表类型、大小、数据和标签参数:

  • cht=p3将图表类型指定为三维饼图。
  • chs=450x200将图表大小指定为 450 像素宽 200 像素高—图表的宽度至少应为其高度的 2 . 5 倍,以便所有标签完全可见。
  • chd=t:60,40以简单的文本格式指定图表数据—此格式由一系列逗号分隔的值组成;通过使用竖线将一个系列与下一个系列分开来指定多个系列,其中第一个数据项(对于第一个饼图扇区)是 60,第二个数据项(对于第二个扇区)是 40。
  • chl=Q1%20(60%)|Q2%20(40%)将饼图扇区的图表标签指定为 Q1 (60%)和 Q2(40%)—标签由竖线分隔,并且必须是 URL 编码的(这就是为什么每个空格字符都替换为%20)。

Google Charts 默认将图表作为 PNG 图像返回。您可以通过在查询字符串中包含chof=gif参数来返回 GIF 图像,或者甚至通过包含chof=json参数来返回 JavaScript 对象符号(JSON)格式的文档(参见[en.wikipedia.org/wiki/JSON](http://en.wikipedia.org/wiki/JSON))。

我已经创建了一个ViewChart应用,它将前面提到的带有查询字符串的 URI 传递给 Google Charts,获取生成的 3D 饼图的 PNG 图像,并显示这个图像。清单 11-15 展示了这个应用的源代码。

清单 11-15。访问谷歌图表网络服务的客户端

import java.io.InputStream;
import java.io.IOException;

import java.net.HttpURLConnection;
import java.net.URL;

import javax.swing.ImageIcon;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;

class ViewChart
{
   final static String BASEURI = "https://chart.googleapis.com/chart?";
   public static void main(String[] args)
   {
      String qs = "cht=p3&chs=450x200&chd=t:60,40&chl=q1%20(60%)|q2%20(40%)";
      ImageIcon ii = doGet(qs);
      if (ii != null)
      {
         JFrame frame = new JFrame("viewchart");
         frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
         frame.setContentPane(new JLabel(ii));

         frame.pack();
         frame.setResizable(false);
         frame.setVisible(true);
      }
   }
   static ImageIcon doGet(String qs)
   {
      try
      {
         URL url = new URL(BASEURI+qs);
         HttpURLConnection httpurlc;
         httpurlc = (HttpURLConnection) url.openConnection();
         httpurlc.setRequestMethod("get");
         httpurlc.setDoInput(true);
         if (httpurlc.getResponseCode() == 200)
         {
            InputStream is = httpurlc.getInputStream();
            byte[] bytes = new byte[10000];
            int b, i = 0;
            while ((b = is.read()) != -1)
            {
               bytes[i++] = (byte) b;
               if (i == bytes.length)
               {
                  byte[] bytes2 = new byte[bytes.length*2];
                  System.arraycopy(bytes, 0, bytes2, 0, i);
                  bytes = bytes2;
               }
            }
            byte[] bytes2 = new byte[i];
            System.arraycopy(bytes, 0, bytes2, 0, i);
            return new ImageIcon(bytes2);
         }
         throw new IOException("http error: "+httpurlc.getResponseCode());
      }
      catch (IOException e)
      {
         JOptionPane.showMessageDialog(null, e.getMessage(), "viewchart",
                                       JOptionPane.ERROR_MESSAGE);
         return null;
      }
   }
}

清单 11-15 相当简单。它的main()方法用查询字符串调用doGet()。如果该方法返回一个javax.swing.ImageIcon对象,则创建一个基于 Swing 的框架窗口,当用户单击窗口标题栏上的 X 按钮时,该窗口被告知终止应用(对于 Windows 和类似的操作系统),创建一个基于图标的标签并将其分配给框架窗口作为其内容窗格,窗口被标签的打包(调整到首选大小)(该标签采用图像图标的图像大小作为其首选大小),窗口被设为不可调整大小,并且窗口被显示。

doGet()方法创建一个URL对象,打开一个到URL实例的 HTTP 连接,指定GET为请求方法,告诉连接它只想输入内容,当响应代码为 200(成功)时继续读取内容。

内容存储在字节数组中。如果数组太小,无法容纳所有内容,那么可以通过创建一个更大的数组,并在System.arraycopy()的帮助下将原始数组的内容复制到新数组中,从而动态调整数组的大小。读取完所有字节后,这个数组被传递给ImageIconImageIcon(byte[] imageData)构造函数,以存储 PNG 图像作为从doGet()返回的ImageIcon对象的基础。

如果出错,就会抛出一个IOException类或它的一个子类的实例(比如java.net.MalformedURLException,这表示传递给URL的构造函数的参数是非法的)。catch 块通过调用javax.swing.JOptionPanevoid showMessageDialog(Component parentComponent, Object message, String title, int messageType)来处理这个异常,通过弹出对话框显示一个合适的错误消息。

编译清单 11-15 ( javac ViewChart.java)并运行应用(java ViewChart)。图 11-8 显示了结果图表。

images

图 11-8。谷歌 RESTful Charts 网络服务返回一个三维饼图图像。

images 注意访问[www.programmableweb.com/apis/directory/](http://www.programmableweb.com/apis/directory/)了解 RESTful 和基于 SOAP 的 web 服务的其他示例。

高级 Web 服务主题

既然创建和访问基于 SOAP 和 RESTful 的 web 服务的基础知识已经介绍完毕,您可能已经准备好学习更高级的内容了。本节介绍五个高级 web 服务主题。

您首先会了解到在较低层次上使用基于 SOAP 的 web 服务的 SAAJ API。然后,您将学习如何创建 JAX-WS 处理程序来记录 SOAP 消息流。接下来,您将学习如何创建和安装一个定制的轻量级 HTTP 服务器来执行身份验证,还将学习如何创建一个 RESTful web 服务来将附件(例如,一个 JPEG 图像)返回给客户机。最后,通过探索提供者和调度客户机之间的相互作用,您将更深入地了解 JAX-WS,,并学习如何创建一个调度客户机,它通过另一个库客户机应用中的另一个Source实例来访问从 web 服务提供者的invoke()方法返回的Source实例。

与 SAAJ 合作

Soap with Attachments API for Java(SAAJ)是用于创建、发送和接收 SOAP 消息的 Java API,这些消息可能有也可能没有 MIME 类型的附件。对于发送和接收 SOAP 消息,SAAJ 是 JAX-WS 的一个低级替代方案。

在概述了 SOAP 消息架构之后,我将带您游览一下 SAAJ。当本文结束时,我将展示一个应用,它使用这个 API 来访问一个基于 SOAP 的 web 服务,以便在整数值和罗马数字之间进行转换。这个应用加强了你对 SAAJ 的理解。

SOAP 消息架构

SOAP 消息是从初始的 SOAP 发送方节点发送到最终的 SOAP 接收方节点的 XML 文档,很可能沿着它的路径通过中间的 SOAP 发送方/接收方节点。SOAP 节点是对 SOAP 消息进行操作的处理逻辑。

SOAP 文档由一个Envelope根元素组成,它封装了一个可选的Header元素和一个非可选的Body元素——参见图 11-9 。

images

图 11-9。SOAP 消息的架构由一个可选的Header元素和一个强制的Body元素组成,后者位于一个Envelope元素内。

Header元素通过称为头块的直接子元素指定应用相关的信息(比如验证谁发送了消息的认证细节)。头块表示数据的逻辑分组,它可以以中间 SOAP 节点或最终接收节点为目标。

虽然标题块是由应用定义的,但是它们的开始标记可能包含以下 SOAP 定义的属性,以指示 SOAP 节点应该如何处理它们:

  • encodingStyle标识用于序列化 SOAP 消息各部分的规则
  • role标识头块的目标 SOAP 节点(通过 URI)——这个 SOAP 1.2 引入的属性取代了 SOAP 1.1 actor属性,后者执行相同的功能
  • mustUnderstand表示头块的处理是否是强制性的(SOAP 1.1 中的值1;SOAP 1.2 中的true或 SOAP 1.1 中的可选值0false在肥皂 1.2)
  • relay表示如果不处理,以 SOAP 接收者为目标的头块是否必须中继到另一个节点——这个属性是在 SOAP 1.2 中引入的

Body元素包含以最终接收者节点为目标的信息。该信息被称为有效负载,由描述错误(web 服务报告的错误)的 SOAP 定义的Fault子元素或特定于 web 服务的子元素组成。

元素包含 web 服务返回给客户端的错误和状态信息。SOAP 1.1 指定了Fault的以下子元素:

  • faultcode:该强制元素以软件可处理的形式提供有关故障的信息。SOAP 定义了一小组涵盖基本错误的 SOAP 错误代码;这个集合可以通过应用进行扩展。
  • faultstring:该强制元素以人类可读的格式提供关于故障的信息。
  • faultactor:该元素包含产生错误的 SOAP 节点的 URI。不是最终 SOAP 接收者的 SOAP 节点在创建错误时必须包含faultactor;最终的 SOAP 接收者不一定要包含这个元素,但是可能会选择这样做。
  • detail:该元素携带与Body元素相关的应用特定的错误信息。当Body的内容不能被成功处理时,它必须存在。detail元素不得用于携带属于标题块的错误信息;属于标题块的详细错误信息被携带在这些块中。

SOAP 1.2 指定了Fault的以下子元素:

  • Code:该强制元素以软件可处理的形式提供有关故障的信息。它包含一个Value元素和一个可选的Subcode元素。
  • Reason:该强制元素以人类可读的格式提供关于故障的信息。Reason包含一个或多个Text元素,每个元素包含不同语言的故障信息。
  • Node:这个元素包含了产生错误的 SOAP 节点的 URI。不是最终 SOAP 接收者的 SOAP 节点在创建错误时必须包含Node;最终的 SOAP 接收者不一定要包含这个元素,但是可能会选择这样做。
  • Role:该元素包含一个 URI,用于识别故障发生时节点所扮演的角色。
  • Detail:该可选元素包含与描述故障的 SOAP 故障代码相关的应用特定的错误信息。它的存在对于错误 SOAP 消息的哪一部分被处理没有任何意义。

清单 11-16 给出了一个 SOAP 消息的例子。

清单 11-16。一条 SOAP 消息,用于调用基于 SOAP 的图书馆 web 服务的getTitle()函数,在给定书号的情况下检索书名

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
                   xmlns:xsd="http://www.w3.org/2001/xmlschema"
                   xmlns:xsi="http://www.w3.org/2001/xmlschema-instance">
   <SOAP-ENV:Header />
   <SOAP-ENV:Body>
      <lns:getTitle xmlns:lns="http://tutortutor.ca/library">
         <isbn xsi:type="xsd:string">9781430234135</isbn>
      </lns:getTitle>
   </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

这个 SOAP 消息描述了对库 web 服务执行其getTitle()功能的请求。此外,它描述了传递给该函数的isbn参数的 ISBN 参数的类型和值。

消息以前缀为SOAP-ENV<Envelope>标记开始,它描述了 SOAP 消息的信封。常用的SOAP-ENV前缀对应于为 SOAP 信封提供模式的 SOAP 1.1 名称空间。xsdxsi前缀对应于 XML 模式结构和 XML 模式实例名称空间,用于表示 XML 模式类型,该类型描述了通过isbn元素传递给getTitle()(一个字符串)的数据类型。

空的Header元素表示没有 SOAP 头。相反,Body元素标识单个getTitle操作请求。

按照 SOAP 1.1 和 1.2 规范的建议,getTitle元素是名称空间限定的。相比之下,getTitleisbn子元素不是名称空间限定的,因为它继承了getTitle的名称空间 SOAP 1.1 和 1.2 规范不要求这样的子元素是名称空间限定的。

SAAJ API 概述

SAAJ 是一个小 API,允许您执行以下任务:

  • 创建端点到端点的连接
  • 创建 SOAP 消息
  • 创建 XML 片段
  • 将内容添加到 SOAP 消息的报头中
  • 向 SOAP 消息体添加内容
  • 创建附件部件并向其添加内容
  • 访问/添加/修改部分 SOAP 消息
  • 创建/添加/修改 SOAP 错误信息
  • 从 SOAP 消息中提取内容
  • 发送 SOAP 请求-响应消息

SAAJ 与包含 14 个接口和 13 个类的javax.xml.soap包相关联。各种接口和类扩展了它们在org.w3c.dom包中的对应部分,这意味着 SOAP 消息的一部分被组织为节点树。

以下类和接口用于指定 SOAP 消息的结构:

  • SOAPMessage代表整个 SOAP 消息。它包含一个SOAPPart实例和零个或多个AttachmentPart实例。
  • SOAPPart包含一个SOAPEnvelope实例,它代表实际的 SOAP Envelope元素。
  • SOAPEnvelope可选地包含一个SOAPHeader实例,也包含一个强制的SOAPBody实例。
  • SOAPHeader表示 SOAP 消息的头块。
  • SOAPBody包含一个SOAPFault对象或一个SOAPBodyElement对象,其中包含实际的 SOAP 有效负载 XML 内容。
  • SOAPFault存储 SOAP 故障信息。

使用 SAAJ 包括创建 SOAP 连接、创建 SOAP 消息、用内容和可选附件填充每条消息、将消息发送到端点以及检索回复。

您可以通过使用SOAPConnectionFactorySOAPConnection类来创建连接。顾名思义,SOAPConnectionFactory是一个工厂类,用于检索SOAPConnection实例(实际上是抽象SOAPConnection类的子类的实例)。一个SOAPConnection实例代表一个到 web 服务的端到端连接;客户端和 web 服务通过这个连接交换消息。以下示例显示了如何实例化工厂并获取 SOAP 连接:

SOAPConnectionFactory soapcf = SOAPConnectionFactory.newInstance();
SOAPConnection soapc = soapcf.createConnection();

通过调用SOAPConnectionFactorySOAPConnectionFactory newInstance()方法实例化工厂。当无法创建SOAPConnectionFactory实例时,该方法抛出SOAPException。如果一个非 Oracle Java 实现不支持 SAAJ 通信基础设施,这个方法抛出一个java.lang.UnsupportedOperationException类的实例。

实例化SOAPConnectionFactory后,调用该实例的SOAPConnection createConnection()方法创建并返回一个新的SOAPConnection对象。该方法在无法创建该对象时抛出SOAPException

通过使用MessageFactorySOAPMessage类创建一个 SOAP 消息。MessageFactory提供了一对返回MessageFactory实例的方法:

  • MessageFactory newInstance()基于默认的 SOAP 1.1 实现创建一个MessageFactory对象。这个方法遵循一个有序的查找过程来定位MessageFactory实现类。该过程首先检查javax.xml.soap.MessageFactory系统属性,最后调用SAAJMetaFactory类的MessageFactory newMessageFactory(String protocol)方法的一个实例来返回该工厂。该方法在无法创建工厂时抛出SOAPException
  • MessageFactory newInstance(String protocol)创建一个基于由protocol参数指定的 SOAP 实现的MessageFactory对象,该参数是SOAPConstants接口的DEFAULT_SOAP_PROTOCOLDYNAMIC_SOAP_PROTOCOLSOAP_1_1_PROTOCOLSOAP_1_2_PROTOCOL常量之一。该方法在无法创建工厂时抛出SOAPException

实例化MessageFactory后,调用以下方法之一创建一个SOAPMessage实例:

  • SOAPMessage createMessage()用默认的SOAPPartSOAPEnvelopeSOAPBody(最初为空)和SOAPHeader对象创建并返回一个新的SOAPMessage对象(实际上是这个抽象类的一个具体子类的实例)。当无法创建SOAPMessage实例时,该方法抛出SOAPException,当MessageFactory实例的协议为DYNAMIC_SOAP_PROTOCOL时,抛出UnsupportedOperationException
  • 将给定的java.io.InputStream对象的内容内化到一个新的SOAPMessage对象中,并返回这个对象。MimeHeaders实例指定了描述 SOAP 消息各种附件的特定于传输的头。当无法创建SOAPMessage实例时,该方法抛出SOAPException,当从输入流读取数据时抛出IOException,当MessageFactory实例要求传递给headers的参数中存在一个或多个 MIME 头而这些头丢失时抛出IllegalArgumentException

下面的例子展示了如何实例化工厂并创建一个准备好被填充的SOAPMessage对象:

MessageFactory mf = MessageFactory.newInstance();
SOAPMessage soapm = mf.createMessage();

描述一个 SOAP 消息,后面可选地跟有 MIME 类型的附件。该对象的 SOAP 消息部分由抽象SOAPPart类的具体子类的实例定义。

SOAPPart封装了实现SOAPEnvelope接口的类的实例,SOAPEnvelope实例封装了实现SOAPHeaderSOAPBody接口的类的实例。调用SOAPMessageSOAPPart getSOAPPart()方法返回SOAPPart实例。然后可以调用SOAPPartSOAPEnvelope getEnvelope()方法返回SOAPEnvelope实例,调用SOAPEnvelopeSOAPBody getBody()SOAPHeader getHeader()方法返回SOAPEnvelope实例的SOAPBodySOAPHeader实例。

images 提示因为一个SOAPEnvelope实例默认存储一个空的SOAPHeader实例,所以当不需要这个SOAPHeader实例时,可以通过调用SOAPHeader的 inherited(从javax.xml.soap.Node接口)void detachNode()方法将其移除。

以下示例向您展示了如何从SOAPMessage实例中获取SOAPPartSOAPEnvelopeSOAPBody实例,以及如何分离SOAPHeader实例:

SOAPPart soapp = soapm.getSOAPPart();
SOAPEnvelope soape = soapp.getEnvelope();
SOAPBody soapb = soape.getBody();
soape.getHeader().detachNode();

images 提示 SOAPMessage声明了SOAPBody getSOAPBody()SOAPHeader getSOAPHeader()方法,方便您访问SOAPBodySOAPHeader实例,而不必经过getEnvelope()。调用这些方法相当于分别调用getEnvelope().getBody()getEnvelope().getHeader()

SOAPEnvelope和其他各种接口扩展了SOAPElement,提供了适用于不同种类元素实现实例的方法。例如,SOAPElement addNamespaceDeclaration(String prefix, String uri)方法对于将带有指定的prefixuri值的名称空间声明添加到SOAPEnvelope实例非常有用。以下示例显示了如何将清单 11-16 中所示的xsdxsi名称空间的声明添加到其Envelope元素中:

soape.addNamespaceDeclaration("xsd", "http://www.w3.org/2001/xmlschema");
soape.addNamespaceDeclaration("xsi", "http://www.w3.org/2001/xmlschema-instance");

SOAPBody实例包含内容或错误。向正文添加内容首先需要您创建SOAPBodyElement对象(以存储该内容)并将这些对象添加到SOAPBody实例。这个任务是通过调用SOAPBody的两个addBodyElement()方法中的任何一个来完成的,这两个方法创建SOAPBodyElement对象,将它添加到SOAPBody对象,并返回一个对所创建对象的引用,这样您就可以创建方法调用链(参见第二章中关于链接方法调用的讨论)。

创建 SOAP Body元素的新子元素时,必须以Name实例或QName实例的形式指定一个完全限定名。因为Name接口的 Java 文档声明它可能会因支持QName而被弃用,所以您应该养成使用QName而不是Name的习惯。因此,您应该使用SOAPBodySOAPBodyElement addBodyElement(QName qname)方法,而不是使用该接口的SOAPBodyElement addBodyElement(Name name)方法,如下所示:

QName name = new QName("http://tutortutor.ca/library", "gettitle", "lns");
SOAPElement soapel = soapb.addBodyElement(name);

SOAPBodyElement实例存储子元素实例。您创建这些子元素,并通过调用SOAPElement的各种addChildElement()方法将它们添加到SOAPBodyElement实例中,例如SOAPElement addChildElement(String localName),它创建一个具有指定localName的子元素对象,将这个子元素对象添加到调用该方法的SOAPBodyElement对象中,并返回对所创建的SOAPElement对象的引用,以便将方法调用链接在一起。

然后,您可以通过调用SOAPElementSOAPElement addTextNode(String text)方法将文本节点附加到主体元素或子元素。您还可以调用SOAPElementvoid setAttribute(String name, String value)方法(从SOAPElementorg.w3c.dom.Element祖先接口继承而来)来适当地向子元素添加属性。以下示例演示了:

soapel.addChildElement("isbn").addTextNode("9781430234135").setAttribute("xsi:type",
                                                                         "xsd:string");

附件是抽象AttachmentPart类的具体子类的实例。如果您需要在 SOAP 消息中包含一个附件,调用SOAPMessagecreateAttachmentPart()方法之一来创建并返回一个AttachmentPart对象。配置完这个对象后,调用SOAPMessagevoid addAttachmentPart(AttachmentPart attachmentPart)方法将给定的attachmentPart引用的对象添加到这个SOAPMessage对象中。

要发送 SOAP 消息并接收回复,调用SOAPConnectionSOAPMessage call(SOAPMessage request, Object to)方法。指定的请求消息被发送到由to标识的端点,该端点可以是一个StringURL实例。当一个 SOAP 问题发生时,这个方法抛出SOAPException,并且阻塞,直到它接收到一个 SOAP 消息,它作为一个SOAPMessage对象返回。以下示例提供了一个演示:

String endpoint = "http://tutortutor.ca/library/gettitle";
// Send the request message identified by soapm to the web service at the specified
// endpoint and return the response message.
SOAPMessage response = soapc.call(soapm, endpoint);

或者,您可以调用SOAPConnectionSOAPMessage get(Object to)方法来请求 SOAP 消息。和call()一样,get()阻塞直到有回复,当出现 SOAP 问题时抛出SOAPException

在完成您的call()和/或get()调用之后,调用SOAPConnectionvoid close()方法来关闭到端点的连接。如果这个方法已经被调用,那么随后关闭连接的尝试将导致抛出一个SOAPException实例。

罗马数字和 SAAJ

为了在更实际的环境中演示 SAAJ,我创建了一个RomanNumerals应用,它使用这个 API 与基于 SOAP 的罗马数字转换 web 服务进行通信,该服务在罗马数字和十进制整数值之间进行转换。这个 web 服务的 WSDL 文档位于[www.ebob42.com/cgi-bin/Romulan.exe/wsdl/IRoman](http://www.ebob42.com/cgi-bin/Romulan.exe/wsdl/IRoman),并出现在清单 11-17 中。

清单 11-17。 WSDL 为罗马数字/10 进制整数值转换的 web 服务。

<definitions name="iromanservice" targetNamespace="http://ebob42.org/">
   <message name="inttoroman0request">
      <part name="Int" type="xs:int"/>
   </message>
   <message name="IntToRoman0response">

      <part name="return" type="xs:string"/>
   </message>
   <message name="romantoint1request">
      <part name="rom" type="xs:string"/>
   </message>
   <message name="romantoint1response">
      <part name="return" type="xs:int"/>
   </message>
   <portType name="iroman">
      <operation name="**IntToRoman**">
         <input message="tns:inttoroman0request"/>
         <output message="tns:inttoroman0response"/>
      </operation>
      <operation name="**RomanToInt**">
         <input message="tns:romantoint1request"/>
         <output message="tns:romantoint1response"/>
      </operation>
   </portType>
   <binding name="iromanbinding" type="tns:iroman">
      <soap:binding style="rpc" transport="http://schemas.xmlsoap.org/soap/http"/>
      <operation name="inttoroman">
         <soap:operation soapAction="urn:Roman-IRoman#IntToRoman" style="rpc"/>
         <input message="tns:inttoroman0request">
            <soap:body use="encoded"
                       encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
                       namespace="urn:Roman-IRoman"/>
         </input>
         <output message="tns:IntToRoman0Response">
            <soap:body use="encoded"
                       encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
                       namespace="urn:Roman-IRoman"/>
         </output>
      </operation>
      <operation name="romantoint">
         <soap:operation soapAction="urn:Roman-IRoman#romantoint" style="rpc"/>
         <input message="tns:romantoint1request">
            <soap:body use="encoded"
                       encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
                       namespace="urn:Roman-IRoman"/>
         </input>
         <output message="tns:romantoint1response">
            <soap:body use="encoded"
                       encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"
                       namespace="urn:Roman-IRoman"/>
         </output>
      </operation>
   </binding>
   <service name="IRomanservice">
      <port name="IRomanport" binding="tns:IRomanbinding">
         <soap:address
               location="http://www.ebob42.com/cgi-bin/romulan.exe/soap/IRoman"/>
      </port>

   </service>
</definitions>

清单 11-17 的 WSDL 文档提供了构造 SOAP 请求和响应消息的重要信息——注意缺少了一个types元素,因为服务只使用简单类型中内置的 XML 模式;再者,文档样式是rpc。这些信息包括IntToRomanRomanToInt操作名(应用调用它们来执行转换)以及参数和返回类型信息。这个清单还显示了服务的端点地址。

清单 11-18 揭示了RomanNumerals.java

清单 11-18。使用 SAAJ 访问罗马数字转换 web 服务

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.EventQueue;
import java.awt.GradientPaint;
import java.awt.Graphics;
import java.awt.Graphics2D;

import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;

import java.io.IOException;

import java.util.Iterator;

import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JTextField;

import javax.swing.border.Border;

import javax.xml.namespace.QName;

import javax.xml.soap.MessageFactory;
import javax.xml.soap.SOAPBody;
import javax.xml.soap.SOAPBodyElement;
import javax.xml.soap.SOAPConnection;
import javax.xml.soap.SOAPConnectionFactory;
import javax.xml.soap.SOAPConstants;
import javax.xml.soap.SOAPElement;
import javax.xml.soap.SOAPEnvelope;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPHeader;
import javax.xml.soap.SOAPMessage;

class RomanNumerals extends JFrame
{

   private JTextField txtResult;
   RomanNumerals()
   {
      super("romannumerals");
      setDefaultCloseOperation(EXIT_ON_CLOSE);
      // Create a gradient panel in which to present the GUI.
      GPanel pnl = new GPanel();
      pnl.setLayout(new BorderLayout());
      // Build input panel.
      JPanel pnlInput = new JPanel();
      Border inner = BorderFactory.createEtchedBorder();
      Border outer = BorderFactory.createEmptyBorder(10, 10, 10, 10);
      pnlInput.setBorder(BorderFactory.createCompoundBorder(outer, inner));
      pnlInput.setOpaque(false);
      pnlInput.add(new JLabel("enter roman numerals or integer:"));
      final JTextField txtInput = new JTextField(15);
      pnlInput.add(txtInput);
      pnl.add(pnlInput, BorderLayout.NORTH);
      // Build buttons panel.
      JPanel pnlButtons = new JPanel();
      inner = BorderFactory.createEtchedBorder();
      outer = BorderFactory.createEmptyBorder(10, 10, 10, 10);
      pnlButtons.setBorder(BorderFactory.createCompoundBorder(outer, inner));
      pnlButtons.setOpaque(false);
      JButton btnToRoman = new JButton("to roman");
      ActionListener alToRoman;
      alToRoman = new ActionListener()
                  {
                     @Override
                     public void actionPerformed(ActionEvent ae)
                     {

                        try
                        {
                           String roman = toRoman(txtInput.getText());
                           txtResult.setText(roman);
                        }
                        catch (SOAPException se)
                        {
                           JOptionPane.showMessageDialog(RomanNumerals.this,
                                                         se.getMessage());
                        }
                     }
                  };
      btnToRoman.addActionListener(alToRoman);
      pnlButtons.add(btnToRoman);
      JButton btnToInteger = new JButton("to integer");
      ActionListener alToInteger;
      alToInteger = new ActionListener()
                    {
                       @Override
                       public void actionPerformed(ActionEvent ae)
                       {
                          try
                          {
                             String integer = toInteger(txtInput.getText());
                             txtResult.setText(integer);
                          }
                          catch (SOAPException se)
                          {
                             JOptionPane.showMessageDialog(RomanNumerals.this,
                                                           se.getMessage());
                          }
                       }
                    };
      btnToInteger.addActionListener(alToInteger);
      pnlButtons.add(btnToInteger);
      pnl.add(pnlButtons, BorderLayout.CENTER);
      // Build result panel.
      JPanel pnlResult = new JPanel();
      inner = BorderFactory.createEtchedBorder();
      outer = BorderFactory.createEmptyBorder(10, 10, 10, 10);
      pnlResult.setBorder(BorderFactory.createCompoundBorder(outer, inner));
      pnlResult.setOpaque(false);
      pnlResult.add(new JLabel("result:"));
      txtResult = new JTextField(35);
      pnlResult.add(txtResult);
      pnl.add(pnlResult, BorderLayout.SOUTH);
      setContentPane(pnl);
      pack();
      setResizable(false);
      setLocationRelativeTo(null); // center on the screen
      setVisible(true);
   }
   String toInteger(String input) throws SOAPException
   {
      // Build a request message. The first step is to create an empty message
      // via a message factory. The default SOAP 1.1 message factory is used.
      MessageFactory mfactory = MessageFactory.newInstance();
      SOAPMessage request = mfactory.createMessage();
      // The request SOAPMessage object contains a SOAPPart object, which
      // contains a SOAPEnvelope object, which contains an empty SOAPHeader
      // object followed by an empty SOAPBody object.
      // Detach the header since a header is not required. This step is
      // optional.
      SOAPHeader header = request.getSOAPHeader();
      header.detachNode();
      // Access the body so that content can be added.
      SOAPBody body = request.getSOAPBody();
      // Add the RomanToInt operation body element to the body.
      QName bodyName = new QName("http://ebob42.org/", "romantoint", "tns");
      SOAPBodyElement bodyElement = body.addBodyElement(bodyName);
      // Add the Rom child element to the RomanToInt body element.
      QName name = new QName("rom");
      SOAPElement element = bodyElement.addChildElement(name);

      element.addTextNode(input).setAttribute("xsi:type", "xs:string");
      // Add appropriate namespaces and an encoding style to the envelope.
      SOAPEnvelope env = request.getSOAPPart().getEnvelope();
      env.addNamespaceDeclaration("env",
                                  "http://schemas.xmlsoap.org/soap/envelop/");
      env.addNamespaceDeclaration("enc",
                                  "http://schemas.xmlsoap.org/soap/encoding/");
      env.setEncodingStyle(SOAPConstants.URI_NS_SOAP_ENCODING);
      env.addNamespaceDeclaration("xs", "http://www.w3.org/2001/xmlschema");
      env.addNamespaceDeclaration("xsi",
                                  "http://www.w3.org/2001/xmlschema-instance");
      // Output the request just built to standard output, to see what the
      // SOAP message looks like (which is useful for debugging).
      System.out.println("\nsoap request:\n");
      try
      {
         request.writeTo(System.out);
      }
      catch (IOException ioe)
      {
         JOptionPane.showMessageDialog(RomanNumerals.this,
                                       ioe.getMessage());
      }
      System.out.println();
      // Prepare to send message by obtaining a connection factory and creating
      // a connection.
      SOAPConnectionFactory factory = SOAPConnectionFactory.newInstance();
      SOAPConnection con = factory.createConnection();
      // Identify the message's target.
      String endpoint = "http://www.ebob42.com/cgi-bin/romulan.exe/soap/IRoman";
      // Call the Web service at the target using the request message. Capture
      // the response message and send it to standard output.
      SOAPMessage response = con.call(request, endpoint);
      System.out.println("\nsoap response:\n");
      try
      {
         response.writeTo(System.out);
      }
      catch (IOException ioe)
      {
         JOptionPane.showMessageDialog(RomanNumerals.this,
                                       ioe.getMessage());
      }
      // Close the connection to release resources.
      con.close();
      // Return a response consisting of the reason for a SOAP Fault or the
      // value of the RomanToIntResponse body element's return child element.
      if (response.getSOAPBody().hasFault())
         return response.getSOAPBody().getFault().getFaultString();
      else
      {
         body = response.getSOAPBody();

         bodyName = new QName("urn:Roman-IRoman", "romantointresponse", "ns1");
         Iterator iter = body.getChildElements(bodyName);
         bodyElement = (SOAPBodyElement) iter.next();
         iter = bodyElement.getChildElements(new QName("return"));
         return ((SOAPElement) iter.next()).getValue();
      }
   }
   String toRoman(String input) throws SOAPException
   {
      // Build a request message. The first step is to create an empty message
      // via a message factory. The default SOAP 1.1 message factory is used.
      MessageFactory mfactory = MessageFactory.newInstance();
      SOAPMessage request = mfactory.createMessage();
      // The request SOAPMessage object contains a SOAPPart object, which
      // contains a SOAPEnvelope object, which contains an empty SOAPHeader
      // object followed by an empty SOAPBody object.
      // Detach the header since a header is not required. This step is
      // optional.
      SOAPHeader header = request.getSOAPHeader();
      header.detachNode();
      // Access the body so that content can be added.
      SOAPBody body = request.getSOAPBody();
      // Add the IntToRoman operation body element to the body.
      QName bodyName = new QName("http://ebob42.org/", "inttoroman", "tns");
      SOAPBodyElement bodyElement = body.addBodyElement(bodyName);
      // Add the Int child element to the IntToRoman body element.
      QName name = new QName("int");
      SOAPElement element = bodyElement.addChildElement(name);
      element.addTextNode(input).setAttribute("xsi:type", "xs:int");
      // Add appropriate namespaces and an encoding style to the envelope.
      SOAPEnvelope env = request.getSOAPPart().getEnvelope();
      env.addNamespaceDeclaration("env",
                                  "http://schemas.xmlsoap.org/soap/envelop/");
      env.addNamespaceDeclaration("enc",
                                  "http://schemas.xmlsoap.org/soap/encoding/");
      env.setEncodingStyle(SOAPConstants.URI_NS_SOAP_ENCODING);
      env.addNamespaceDeclaration("xs", "http://www.w3.org/2001/xmlschema");
      env.addNamespaceDeclaration("xsi",
                                  "http://www.w3.org/2001/xmlschema-instance");
      // Output the request just built to standard output, to see what the
      // SOAP message looks like (which is useful for debugging).
      System.out.println("\nsoap request:\n");
      try
      {
         request.writeTo(System.out);
      }
      catch (IOException ioe)
      {
         JOptionPane.showMessageDialog(RomanNumerals.this,
                                       ioe.getMessage());
      }
      System.out.println();

      // Prepare to send message by obtaining a connection factory and creating
      // a connection.
      SOAPConnectionFactory factory = SOAPConnectionFactory.newInstance();
      SOAPConnection con = factory.createConnection();
      // Identify the message's target.
      String endpoint = "http://www.ebob42.com/cgi-bin/romulan.exe/soap/IRoman";
      // Call the Web service at the target using the request message. Capture
      // the response message and send it to standard output.
      SOAPMessage response = con.call(request, endpoint);
      System.out.println("\nsoap response:\n");
      try
      {
         response.writeTo(System.out);
      }
      catch (IOException ioe)
      {
         JOptionPane.showMessageDialog(RomanNumerals.this,
                                       ioe.getMessage());
      }
      // Close the connection to release resources.
      con.close();
      // Return a response consisting of the reason for a SOAP Fault or the
      // value of the IntToRomanResponse body element's return child element.
      if (response.getSOAPBody().hasFault())
         return response.getSOAPBody().getFault().getFaultString();
      else
      {
         body = response.getSOAPBody();
         bodyName = new QName("urn:Roman-IRoman", "inttoromanresponse", "ns1");
         Iterator iter = body.getChildElements(bodyName);
         bodyElement = (SOAPBodyElement) iter.next();
         iter = bodyElement.getChildElements(new QName("return"));
         return ((SOAPElement) iter.next()).getValue();
      }
   }
   public static void main(String[] args)
   {
      Runnable r = new Runnable()
                   {
                      @Override
                      public void run()
                      {
                         new RomanNumerals();
                      }
                   };
      EventQueue.invokeLater(r);
   }
}
class GPanel extends JPanel
{
   private GradientPaint gp;
   @Override

   public void paintComponent(Graphics g)
   {
      if (gp == null)
         gp = new GradientPaint(0, 0, Color.pink, 0, getHeight(), Color.orange);
      // Paint a nice gradient background with pink at the top and orange at
      // the bottom.
      ((Graphics2D) g).setPaint(gp);
      g.fillRect(0, 0, getWidth(), getHeight());
   }
}

清单 11-18 将创建用户界面的 Swing/Abstract Window Toolkit 代码和与罗马数字转换 web 服务通信的 SAAJ 代码结合起来。

用户界面由一对文本字段和一对按钮组成。其中一个文本字段用于输入要转换的值的罗马数字或十进制整数位数。另一个文本字段显示转换结果。单击其中一个按钮,将罗马数字转换为整数;单击其他按钮实现相反的转换。为响应按钮点击,调用String toInteger(String input)方法或String toRoman(String input)方法来执行转换。

因为我在第七章中广泛讨论了 Java 用户界面 API 的基础知识,所以我在这里不再赘述。相反,考虑一下GPanel(渐变面板)类。

我引入了GPanel,这样我就可以为应用的窗口生成彩色背景。一些用户界面设计师可能不同意将粉色到橙色的渐变(从初始颜色到最终颜色的渐变)作为窗口背景,但是我喜欢。(毕竟,情人眼里出西施。)

GPanel扩展了JPanel来描述一个定制面板,每当调用它继承的void paintComponent(Graphics g)方法时,它的表面就被涂上一层渐变。当窗口第一次显示时,以及当窗口最小化后恢复时(至少在 Windows 平台上),会发生这种情况。

GPanel使用java.awt.GradientPaint类绘制渐变。(我本来可以用 Java 6 引入的java.awt.LinearGradientPaint类来代替,但是抛了个硬币,最后用了GradientPaint。)传递给这个类的构造函数的前两个参数标识绘制渐变的矩形区域的左上角(在用户空间中——见第七章),第三个参数指定渐变顶部的颜色,第四个和第五个参数标识矩形区域的右下角,最后一个参数标识渐变底部的颜色。

images 注意GradientPaint的实例化演示了惰性初始化,其中一个对象直到第一次被需要时才被创建。查看 Wikipedia 的“惰性初始化”条目([en.wikipedia.org/wiki/Lazy_initialization](http://en.wikipedia.org/wiki/Lazy_initialization))了解更多关于这种模式的信息。

理想情况下,用户界面的组件出现在渐变背景上,而不是中间背景上。但是,因为用户界面是从添加到渐变面板的组件面板创建的,所以渐变面板的表面将不会通过这些“上部”面板显示,除非通过使用false 作为参数调用它们的void setOpaque(boolean opaque)方法使它们透明。例如,pnlInput.setOpaque(false);使输入面板(包含标签和输入文本字段的面板)透明,以便渐变背景显示出来。

清单 11-18 使用SOAPMessagevoid writeTo(OutputStream out)方法向标准输出流输出请求或响应消息。您会发现这个特性有助于理解 SAAJ API 调用和构造的 SOAP 消息之间的关系,尤其是当您很难理解 API 调用时。当您已经用 SEI 和 SIB 创建了一个基于 SOAP 的 web 服务,并试图创建一个基于 SAAJ 的客户端时,这个特性也很有帮助。

编译清单 11-18 ( javac RomanNumerals.java)并运行这个应用(java RomanNumerals)。图 11-10 显示了从 2011 年到 MMXI 转换的示例窗口。

images

图 11-10。将 2011 转换为罗马数字。

另外RomanNumerals输出以下请求和响应 SOAP 消息:

Soap request:

<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:enc="http://schemas.xmlsoap.org/soap/encoding/"
xmlns:env="http://schemas.xmlsoap.org/soap/envelop/"
xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-
instance" SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-
ENV:Body><tns:IntToRoman xmlns:tns="http://eBob42.org/"><Int
xsi:type="xs:int">**2011**</Int></tns:IntToRoman></SOAP-ENV:Body></SOAP-ENV:Envelope>

Soap response:

<?xml version="1.0"?>
<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:SOAP-
ENC="http://schemas.xmlsoap.org/soap/encoding/"><SOAP-ENV:Body SOAP-
ENC:encodingStyle="http://schemas.xmlsoap.org/soap/envelope/"><NS1:IntToRomanResponse
xmlns:NS1="urn:Roman-IRoman"><return
xsi:type="xsd:string">**MMXI**</return></NS1:IntToRomanResponse></SOAP-ENV:Body></SOAP-
ENV:Envelope>

尽管输出被紧密地打包在一起,难以阅读,但是您可以清楚地看到请求2011和响应MMXI的值。

清单 11-18 的toInteger()toRoman()方法中的每一个都通过首先检查响应消息的正文来提取响应值,以了解它是否描述了一个错误。这个任务是通过调用SOAPBodyboolean hasFault()方法完成的。如果该方法返回 true,SOAPBodySOAPFault getFault()方法被调用以返回一个对象,该对象根据SOAPFault接口的方法描述故障,并且SOAPFaultString getFaultString()方法被调用以返回基于字符串的故障消息。

如果hasFault()返回 false,消息体提供必须提取的响应值。以下摘自toRoman()方法的摘录处理了这个提取任务:

body = response.getSOAPBody();
bodyName = new QName("urn:Roman-IRoman", "IntToRomanResponse", "NS1");
Iterator iter = body.getChildElements(bodyName);
bodyElement = (SOAPBodyElement) iter.next();
iter = bodyElement.getChildElements(new QName("return"));
return ((SOAPElement) iter.next()).getValue();

在调用SOAPMessageSOAPBody getSOAPBody()便利方法返回描述 SOAP 消息体的SOAPBody对象后,摘录创建了一个QName对象,它标识了IntToRomanResponse元素的限定名。然后这个对象被传递给SOAPBody的继承的Iterator getChildElements(QName qname)方法以返回一个java.util.Iterator实例,该实例将用于迭代Body元素的所有IntToRomanResponse子元素。

因为只有一个这样的子元素,所以只需调用一次next()就可以返回这个元素,作为一个SOAPBodyElement实例。这个实例用于调用getChildElements(),但是这次使用的是return元素的限定名。调用返回的迭代器的next()方法提取return元素作为SOAPElement实例,在这个实例上调用getValue()返回return元素的值,恰好是MMXI

用 JAX-WS 处理程序记录 SOAP 消息

RomanNumerals应用使用SOAPMessagevoid writeTo(OutputStream out)方法将 SOAP 消息转储到标准输出流。如果您想在清单 11-7 的TempVerterClient应用的上下文中完成这项任务,您需要安装一个 JAX-WS 处理程序。

JAX-WS 允许您在 web 服务类、客户端类或两者上安装处理程序链,以执行请求和响应消息的定制处理。例如,您可以使用处理程序向消息中添加安全信息或记录消息详细信息。

一个处理程序是一个类的实例,它最终通过以下方法实现了javax.xml.ws.handler.Handler<C extends MessageContext>接口:

  • 在 MEP 结束时,JAX-WS 运行时发送消息、故障或异常之前,调用void close(MessageContext context)。此方法让处理程序清理用于处理仅请求或请求-响应消息交换的任何资源。
  • 调用boolean handleFault(C context)进行故障信息处理。当处理程序希望继续处理错误消息时,此方法返回 true 否则,它返回 false。它可能抛出javax.xml.ws.ProtocolException(WebServiceException的子类)或RuntimeException来使 JAX-WS 运行时停止处理程序的错误处理并分派错误。
  • boolean handleMessage(C context)被调用用于入站和出站消息的正常处理。当处理程序希望继续处理此类消息时,此方法返回 true 否则,它返回 false。它可能抛出ProtocolExceptionRuntimeException,导致 JAX-WS 运行时停止处理程序的正常消息处理,并生成一个错误。

每个方法都用一个MessageContext或子接口参数调用,该参数存储了一个属性映射,供处理程序相互通信或用于其他目的。例如,MessageContext.MESSAGE_OUTBOUND_PROPERTY存储一个标识消息方向的Boolean对象。在请求过程中(从客户端到 web 服务),从客户端处理程序的角度来看,这个属性的值是Boolean.TRUE,从 web 服务处理程序的角度来看,这个属性的值是Boolean.FALSE

JAX WS 支持逻辑和协议处理程序。一个逻辑处理器独立于消息协议(它只能访问消息有效载荷)并且与javax.xml.ws.handler.LogicalMessageContextjavax.xml.ws.handler.LogicalHandler<C extends LogicalMessageContext>接口相关联。相比之下,协议处理器被绑定到特定的协议;JAX-WS 支持带有javax.xml.ws.handler.soap.SOAPMessageContextjavax.xml.ws.handler.soap.SOAPHandler接口的 SOAP 协议处理程序。

为了记录 SOAP 消息流,我们需要使用SOAPMessageContextSOAPHandler。清单 11-19 展示了一个SOAPLoggingHandler类,它实现了SOAPHandler<SOAPMessageContext>通过将 SOAP 消息输出到标准输出设备来记录消息流。

清单 11-19。将 SOAP 消息记录到标准输出

import java.io.IOException;
import java.io.PrintStream;

import java.util.Map;
import java.util.Set;

import javax.xml.namespace.QName;

import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPMessage;

import javax.xml.ws.handler.MessageContext;

import javax.xml.ws.handler.soap.SOAPHandler;
import javax.xml.ws.handler.soap.SOAPMessageContext;

class SOAPLoggingHandler implements SOAPHandler<SOAPMessageContext>
{
   private static PrintStream out = System.out;
   @Override
   public Set<QName> getHeaders()
   {
      return null;
   }
   @Override
   public void close(MessageContext messageContext)
   {
   }
   @Override

   public boolean handleFault(SOAPMessageContext soapmc)
   {
      log(soapmc);
      return true;
   }
   @Override
   public boolean handleMessage(SOAPMessageContext soapmc)
   {
      log(soapmc);
      return true;
   }
   private void log(SOAPMessageContext soapmc)
   {
      Boolean outboundProperty = (Boolean)
         soapmc.get(MessageContext.MESSAGE_OUTBOUND_PROPERTY);
      if (outboundProperty.booleanValue())
         out.println("Outbound message:");
      else
         out.println("Inbound message:");
      SOAPMessage soapm = soapmc.getMessage();
      try
      {
         soapm.writeTo(out);
         out.println("\n");
      }
      catch (IOException|SOAPException e)
      {
         out.println("Handler exception: "+e);
      }
   }
}

SOAPLoggingHandler首先声明一个名为outjava.io.PrintStream字段来标识目的地。虽然System.out被分配给了out,但是您可以为该字段分配不同的输出流,以便将 SOAP 消息记录到另一个目的地。

SOAPHandler引入了一个Set<QName> getHeaders()方法,用于通知 JAX-WS 运行时处理程序负责处理的 SOAP 消息头。该方法为处理程序可以处理的 SOAP 消息头块返回一组限定名。尽管我们必须实现这个方法,但它返回null,因为没有要处理的头。

images 注意吉姆·怀特的“在 JAX-WS SOAPHandlers 中使用标题”博客文章([www.intertech.com/Blog/post/Working-with-Headers-in-JAX-WS-SOAPHandlers.aspx](http://www.intertech.com/Blog/post/Working-with-Headers-in-JAX-WS-SOAPHandlers.aspx))展示了getHeaders()的用处。

覆盖的close()方法什么也不做,因为没有需要清理的资源。相反,handleFault()handleMessage()调用私有的log()方法来记录 SOAP 消息。

log()方法使用其SOAPMessageContext参数来获取标识为MessageContext.MESSAGE_OUTBOUND_PROPERTY的属性的值。返回值决定记录的是Inbound message字符串还是Outbound message字符串。log() next 使用该参数调用SOAPMessage getMessage()方法,该方法返回一个SOAPMessage对象,调用该对象的write(Object o)方法将 SOAP 消息写入由out标识的流。

您需要实例化这个类,并将结果实例添加到客户端或 web 服务的处理程序链中。使用@HandlerChain注释将这个处理程序添加到 web 服务的处理程序链中。相比之下,清单 11-20 揭示了向客户端的处理程序链添加处理程序的编程方法。

清单 11-20。SOAPHandler实例添加到客户端的处理程序链

import java.net.URL;

import java.util.List;

import javax.xml.namespace.QName;

import javax.xml.ws.Binding;
import javax.xml.ws.BindingProvider;
import javax.xml.ws.Service;

import javax.xml.ws.handler.Handler;

import ca.tutortutor.tv.TempVerter;

class TempVerterClient
{
   public static void main(String[] args) throws Exception
   {
      URL url = new URL("http://localhost:9901/TempVerter?wsdl");
      QName qname = new QName("http://tv.tutortutor.ca/",
                              "TempVerterImplService");
      Service service = Service.create(url, qname);
      qname = new QName("http://tv.tutortutor.ca/", "TempVerterImplPort");
      TempVerter tv = service.getPort(qname, TempVerter.class);
//      TempVerter tv = service.getPort(TempVerter.class);
      BindingProvider bp = (BindingProvider) tv;
      Binding binding = bp.getBinding();
      List<Handler> hc = binding.getHandlerChain();
      hc.add(new SOAPLoggingHandler());
      binding.setHandlerChain(hc);
      System.out.println(tv.c2f(37.0)+"\n");
      System.out.println(tv.f2c(212.0)+"\n");
   }
}

清单 11-20 的main()方法访问客户端的处理程序链,并通过完成以下步骤将SOAPLoggingHandler的一个实例插入该链:

  1. 将从getPort()返回的代理实例转换为javax.xml.ws.BindingProvider,因为代理实例的类实现了这个接口。BindingProvider为请求和响应消息处理提供对协议绑定和相关上下文对象的访问。
  2. 调用BindingProviderBinding getBinding()方法返回协议绑定实例,该实例是最终实现javax.xml.ws.Binding接口的类的实例——该类实际上实现了Bindingjavax.xml.ws.soap.SOAPBinding子接口。
  3. 在这个实例上调用BindingList<Handler> getHandlerChain()方法来返回处理程序链的副本。
  4. 实例化SOAPLoggingHandler并将该实例添加到Handler实例的java.util.List实例中。
  5. 将这个处理程序列表传递给Bindingvoid setHandlerChain(List<Handler> chain)方法。

编译清单 11-20 的内容。假设TempVerterPublisher正在运行,运行TempVerterClient。您应该观察到以下输出:

Outbound message:
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body><ns2:c2f![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) xmlns:ns2="http://tv.tutortutor.ca/"><arg0>37.0</arg0></ns2:c2f></S:Body></S:E
nvelope>

Inbound message:
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Header/><S:Body><ns2:c2fResponse ![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)xmlns:ns2="http://tv.tutortutor.ca/"><return>98.6</return></ns2:c2fResponse></S:Body><![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)/S:Envelope>

98.6

Outbound message:
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Body><ns2:f2c![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) xmlns:ns2="http://tv.tutortutor.ca/"><arg0>212.0</arg0></ns2:f2c></S:Body></S:Envelope>

Inbound message:
<S:Envelope![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg) xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"><S:Header/><S:Body><ns2:f2cResponse ![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)xmlns:ns2="http://tv.tutortutor.ca/"><return>100.0</return></ns2:f2cResponse></S:Body>![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)</S:
Envelope>

100.0

S:ns2:名称空间前缀由 JAX-WS 生成。

images 注意要了解关于 SOAP 消息处理程序的更多信息(尤其是关于使用@HandlerChain),请查看 Oracle 的“创建和使用 SOAP 消息处理程序”教程([download.oracle.com/docs/cd/E12840_01/wls/docs103/webserv_adv/handlers.html](http://download.oracle.com/docs/cd/E12840_01/wls/docs103/webserv_adv/handlers.html))。

认证和定制轻量级 HTTP 服务器

您可以创建一个定制的轻量级 HTTP 服务器,为测试 web 服务提供额外的特性,并用您的服务器替换为响应Endpoint.publish()调用而启动的默认轻量级 HTTP 服务器。使这成为可能的是,Endpointvoid publish(Object serverContext)方法可以接受抽象com.sun.net.httpserver.HTTPContext类的子类的实例作为它的参数。

images 注意你可以在[download.oracle.com/javase/7/docs/jre/api/net/httpserver/spec/com/sun/net/httpserver/package-summary.html](http://download.oracle.com/javase/7/docs/jre/api/net/httpserver/spec/com/sun/net/httpserver/package-summary.html)找到关于HTTPContext和其余com.sun.net.httpserver包的接口和类的 JDK 7 文档。

例如,假设你想测试你的 web 服务的基本认证——我在第九章中介绍了这个主题。在客户端,您安装一个默认的身份验证器,它向 web 服务提供用户名和密码。清单 11-21 在TempVerterClient的上下文中揭示了这个认证器。

清单 11-21。支持与TempVerterClient应用的基本认证

import java.net.Authenticator;
import java.net.PasswordAuthentication;
import java.net.URL;

import javax.xml.namespace.QName;

import javax.xml.ws.Service;

import ca.tutortutor.tv.TempVerter;

class TempVerterClient
{
   public static void main(String[] args) throws Exception
   {
      Authenticator auth;
      auth = new Authenticator()
      {
         @Override
         protected PasswordAuthentication getPasswordAuthentication()
         {
            return new PasswordAuthentication("x", new char[] { 'y' });
         }
      };
      Authenticator.setDefault(auth);
      URL url = new URL("http://localhost:9901/TempVerter?wsdl");
      QName qname = new QName("http://tv.tutortutor.ca/",

                              "TempVerterImplService");
      Service service = Service.create(url, qname);
      qname = new QName("http://tv.tutortutor.ca/", "TempVerterImplPort");
      TempVerter tv = service.getPort(qname, TempVerter.class);
//      TempVerter tv = service.getPort(TempVerter.class);
      System.out.println(tv.c2f(37.0));
      System.out.println(tv.f2c(212.0));
   }
}

为了简单起见,清单 11-21 在源代码中嵌入了x作为用户名和y作为密码。一个更有用、更安全的应用会提示这些信息。在运行时,Java 虚拟机调用getPasswordAuthentication()来获取这些凭证,并在 HTTP 服务器请求时让它们可用。

如果 HTTP 服务器没有发出请求,这个方法就不会被调用,我们当前版本的TempVerterPublisher永远不会导致 HTTP 服务器发出这个请求。然而,您可以安装一个定制的服务器来产生这个请求,清单 11-22 中的提供了一个增强的TempVerterPublisher应用来完成这个任务。

清单 11-22。支持与TempVerterPublisher应用的基本认证

import java.io.IOException;

import java.net.InetSocketAddress;

import javax.xml.ws.Endpoint;

import com.sun.net.httpserver.BasicAuthenticator;
import com.sun.net.httpserver.HttpContext;
import com.sun.net.httpserver.HttpServer;

import ca.tutortutor.tv.TempVerterImpl;

class TempVerterPublisher
{
   public static void main(String[] args) throws IOException
   {
      HttpServer server = HttpServer.create(new InetSocketAddress(9901), 0);
      HttpContext context = server.createContext("/TempVerter");
      BasicAuthenticator auth;
      auth = new BasicAuthenticator("myAuth")
      {
         @Override
         public boolean checkCredentials(String username, String password)
         {
            return username.equals("x") && password.equals("y");
         }
      };
      context.setAuthenticator(auth);
      Endpoint endpoint = Endpoint.create(new TempVerterImpl());
      endpoint.publish(context);
      server.start();

   }
}

main()方法首先创建一个HTTPServer实例,描述连接到本地主机端口 9901 的 HTTP 服务器。这个方法接下来创建/TempVerter上下文,并返回结果HttpContext子类对象。

接下来,抽象的com.sun.net.httpserver.BasicAuthenticator类被匿名子类化以描述 HTTP 基本认证的服务器端实现;它的boolean checkCredentials(String username, String password)方法被调用来在基本认证者领域的上下文中验证给定的名称和密码。对于有效的凭据,此方法返回 true,当凭据无效时,返回 false。

BasicAuthenticator实例传递给HttpContextAuthenticator setAuthenticator(Authenticator auth)方法后,调用EndpointEndpoint create(Object implementor)方法,以指定的TempVerterImpl实例作为implementor的参数创建一个Endpoint实例。然后用之前的上下文调用这个方法的void publish(Object serverContext)方法,并启动HttpServer实例。

如果您要运行TempVerterPublisherTempVerterClient,您将在两行连续的输出中观察到98.6后面跟着100.0。但是,如果您修改了TempVerterClient的凭证,您将会看到一个抛出的异常,即当Service service = Service.create(url, qname);试图执行时,无法访问 wouldWSDL 不可访问,因为身份验证失败。

images 注意通过查看 Illya Yalovyy 在[etfdevlab.blogspot.com/2009/12/http-basic-authentication-with-jax-ws.html](http://etfdevlab.blogspot.com/2009/12/http-basic-authentication-with-jax-ws.html)发表的“JAX-WS(客户端)的 HTTP 基本认证”博文,了解更多关于 JAX-WS 和基本认证的信息。

RESTful Web 服务和附件

实现Provider<Source>的 RESTful web 服务不能返回任意 MIME 类型的数据(例如,JPEG 图像)。它们只能返回不带附件的 XML 消息。如果你想返回一个附件(比如一个图片文件),你的 web 服务类必须实现Provider<DataSource>接口;javax.activation.DataSource接口为 JavaBeans 激活框架提供了任意数据集合的抽象。

清单 11-23 展示了一个 Image Publisher RESTful web 服务,它演示了如何使用DataSource和另外两个javax.activation包类型向客户端返回 JPEG 图像。

清单 11-23。响应 GET 请求返回 JPEG 图像

import javax.activation.DataSource;
import javax.activation.FileDataSource;
import javax.activation.MimetypesFileTypeMap;

import javax.annotation.Resource;

import javax.xml.ws.BindingType;
import javax.xml.ws.Endpoint;

import javax.xml.ws.Provider;
import javax.xml.ws.ServiceMode;
import javax.xml.ws.WebServiceContext;
import javax.xml.ws.WebServiceProvider;

import javax.xml.ws.handler.MessageContext;

import javax.xml.ws.http.HTTPBinding;
import javax.xml.ws.http.HTTPException;

@WebServiceProvider
@ServiceMode(value = javax.xml.ws.Service.Mode.MESSAGE)
@BindingType(value = HTTPBinding.HTTP_BINDING)
class ImagePublisher implements Provider<DataSource>
{
   @Resource
   private WebServiceContext wsContext;
   @Override
   public DataSource invoke(DataSource request)
   {
      if (wsContext == null)
         throw new RuntimeException("dependency injection failed on wsContext");
      MessageContext msgContext = wsContext.getMessageContext();
      switch ((String) msgContext.get(MessageContext.HTTP_REQUEST_METHOD))
      {
         case "GET"   : return doGet();
         default      : throw new HTTPException(405);
      }
   }
   private DataSource doGet()
   {
      FileDataSource fds = new FileDataSource("balstone.jpg");
      MimetypesFileTypeMap mtftm = new MimetypesFileTypeMap();
      mtftm.addMimeTypes("image/jpeg jpg");
      fds.setFileTypeMap(mtftm);
      System.out.println(fds.getContentType());
      return fds;
   }
   public static void main(String[] args)
   {
      Endpoint.publish("http://localhost:9902/Image", new ImagePublisher());
   }
}

清单 11-23 的ImagePublisher类描述了一个简单的 RESTful web 服务,它的invoke()方法只支持 HTTP GET动词。它的doGet()方法通过向客户端返回balstone.jpg图像文件的内容来响应GET请求。

doGet()首先实例化javax.activation.FileDataSource类,它实现了DataSource,并且封装了一个要作为附件返回的文件。doGet()将这个文件的名称传递给FileDataSource(String name)构造函数。doGet()接下来实例化javax.activation.MimetypesFileTypeMap类,这样它可以根据文件扩展名jpg将 MIME 类型与 JPEG 文件相关联。这个映射是通过调用MimetypesFileTypeMapvoid addMimeTypes(String mime_types)方法来执行的,传递"image/jpeg jpg"作为参数(image/jpeg是 MIME 类型,jpg是文件扩展名)。

继续,doGet()调用FileDataSourcevoid setFileTypeMap(FileTypeMap map)方法将MimetypesFileTypeMap实例与FileDataSource实例关联起来。

在调用FileDataSourceString getContentType()方法返回文件的 MIME 类型并输出其返回值后,doGet()FileDataSource对象返回给invoke(),后者将该对象返回给 JAX-WS 运行时。

我已经创建了一个用于ImagePublisherImageClient应用。因为这个应用的源代码非常类似于清单 11-15 的ViewChart源代码,所以我不会在这里展示它的代码(为了简洁)——ImageClient.java包含在本书的源代码中。

相反,我将在 web 浏览器环境中演示ImagePublisher。编译ImagePublisher.java并执行这个应用。一旦这个应用运行,启动一个网络浏览器,并在其地址栏中输入localhost:9902/Image。图 11-11 显示了 Mozilla Firefox web 浏览器中的结果——您还应该在ImagePublisher应用的命令窗口中观察到image/jpeg

images

图 11-11。犹他州东部拱门国家公园的平衡石。(图片由公共领域图片提供, [www.public-domain-image.com/nature-landscapes-public-domain-images-pictures/rock-formations-public-domain-images-pictures/balanced-stone-at-arches-national-park.jpg.html](http://www.public-domain-image.com/nature-landscapes-public-domain-images-pictures/rock-formations-public-domain-images-pictures/balanced-stone-at-arches-national-park.jpg.html))

修改清单 11-23 ,要么移除balstone.jpg中的.jpg扩展,要么注释掉mtftm.addMimeTypes("image/jpeg jpg");。重新编译ImagePublisher.java后,重新执行该应用。

在浏览器中重新加载当前网页。您应该(在 Firefox 下)看到一个对话框,将application/octet-stream标识为 MIME 类型,并提示您保存文件或选择查看器——您还会在ImagePublisher的命令窗口中看到此 MIME 类型,而不是看到图像被重新显示。

MIME 类型改变的原因与MimetypesFileTypeMapString getContentType(String filename)方法有关。在某些时候,调用此方法返回指定文件名的内容类型。当这个名字缺少扩展名,或者文件扩展名的 MIME 类型没有被注册(通过调用addMimeTypes()),getContentType()返回默认的application/octet-stream MIME 类型。

在定制使用 HTTP Accept请求头的ImagePublisher(和一个客户机)时,您可能想记住这个场景。(客户端[通过URLConnectionvoid setRequestProperty(String key, String value)方法]指定一个具有一个或多个 MIME 类型的Accept头,告诉服务器客户端想要接收哪种数据;服务器检查该报头,并在报头包含服务器可以接受的 MIME 类型时返回该数据。)

images 如果你想知道清单 11-23 中为什么指定@ServiceMode(value = javax.xml.ws.Service.Mode.MESSAGE),答案是Provider<DataSource>用于发送附件,也就是说javax.xml.ws.Service.Mode.PAYLOAD模式无效。

供应商和派遣客户

本章介绍了使用 JAX-WS 的高级和低级方法。高级方法要求您与 sei 和 sib 一起工作;它简化并隐藏了 Java 方法调用和相应的基于 SOAP 的 XML 消息之间的转换细节。低级方法允许您直接处理 XML 消息,并且必须遵循该方法来实现 RESTful web 服务。

在讨论如何用 JAX-WS 实现 RESTful web 服务时,我向您介绍了这个 API 的Provider<T>接口,客户端调用它的invoke()方法来接收和处理请求,并返回响应。然后,我演示了客户端如何使用HttpURLConnection类与提供者通信。在幕后,JAX-WS 运行时获取从 URL 连接接收的信息,并创建适当的对象传递给invoke()。它还获取从invoke()返回的对象,并通过 URL 连接的输出流使其内容对客户端可用。

JAX-WS 还提供了javax.xml.ws.Dispatch<T>接口作为Provider的客户端伴侣。一个客户端使用Dispatch将消息或消息有效负载构造为 XML,被称为调度客户端。与Provider一样,Dispatch提供了一种T invoke(T)方法。调度客户端调用此方法将消息同步发送到提供者,并从该方法的返回值获取提供者响应。

images 注意 Dispatch提供了额外的调用方法,比如用于异步调用Providerinvoke()方法的Response<T> invokeAsync(T msg)。此方法立即返回;在将来的某个时刻,Providerinvoke()方法的结果在返回的Response<T>对象中可用——javax.xml.ws.Response接口扩展了java.util.concurrent.Future<T>接口,我将在第六章的中讨论。

调度客户端通过调用ServicecreateDispatch()方法之一来获得一个对象,该对象的类实现了Dispatch<T>。例如,Dispatch<T> createDispatch(QName portName, Class<T> type, Service.Mode mode)使用传递给Provider<T>的实际类型参数的指定SourceSOAPMessageDataSource对应物,并通过传递给mode的服务模式(消息或有效负载),返回一个通过portName标识的端口与 web 服务通信的Dispatch实例。

获得Dispatch实例后,调度客户端将创建一个符合传递给T的实际类型参数的对象,并通过调用Dispatchinvoke()方法将该实例传递给 web 服务提供者。为了理解调度客户机和提供者之间的相互作用,考虑一个客户机调用了Dispatch<Source>invoke()方法,并通过Source参数提供了一个 XML 文档。出现以下序列:

  • 提供者的 JAX-WS 运行时将客户端请求分派给Provider<Source>invoke()方法。
  • 提供者将Source实例转换成适当的javax.xml.transform.Result实例(比如 DOM 树),以某种方式处理这个Result实例,并将包含 XML 内容的Source实例返回给 JAX-WS,后者将内容传输给Dispatchinvoke()方法。
  • Dispatchinvoke()方法返回另一个包含 XML 内容的Source实例,调度客户机将其转换成适当的Result实例进行处理。

清单 11-24 通过提供出现在清单 11-14 的LibraryClient应用中的doGet()方法的替代版本来演示这种相互作用。替代的doGet()方法与ServiceDispatch一起工作,而不是与HttpURLConnection一起工作。

清单 11-24修改了LibraryClient应用的doGet()方法作为调度客户端

static void doGet(String isbn) throws Exception
{
   Service service = Service.create(new QName(""));
   String endpoint = "http://localhost:9902/library";
   service.addPort(new QName(""), HTTPBinding.HTTP_BINDING, endpoint);
   Dispatch<Source> dispatch;
   dispatch = service.createDispatch(new QName(""), Source.class,
                                     Service.Mode.MESSAGE);
   Map<String, Object> reqContext = dispatch.getRequestContext();
   reqContext.put(MessageContext.HTTP_REQUEST_METHOD, "get");
   if (isbn != null)

      reqContext.put(MessageContext.QUERY_STRING, "isbn="+isbn);
   Source result;
   try
   {
      result = dispatch.invoke(null);
   }
   catch (Exception e)
   {
      System.err.println(e);
      return;
   }
   try
   {
      DOMResult dom = new DOMResult();
      Transformer t = TransformerFactory.newInstance().newTransformer();
      t.transform(result, dom);
      XPathFactory xpf = XPathFactory.newInstance();
      XPath xp = xpf.newXPath();
      if (isbn == null)
      {
         NodeList isbns = (NodeList) xp.evaluate("/isbns/isbn/text()",
                                                 dom.getNode(),
                                                 XPathConstants.NODESET);
         for (int i = 0; i < isbns.getLength(); i++)
            System.out.println(isbns.item(i).getNodeValue());
      }
      else
      {
         NodeList books = (NodeList) xp.evaluate("/book", dom.getNode(),
                                                 XPathConstants.NODESET);
         isbn = xp.evaluate("@isbn", books.item(0));
         String pubYear = xp.evaluate("@pubyear", books.item(0));
         String title = xp.evaluate("title", books.item(0)).trim();
         String publisher = xp.evaluate("publisher", books.item(0)).trim();
         NodeList authors = (NodeList) xp.evaluate("author", books.item(0),
                                                   XPathConstants.NODESET);
         System.out.println("title: "+title);
         for (int i = 0; i < authors.getLength(); i++)
            System.out.println("author: "+authors.item(i).getFirstChild()
                                                 .getNodeValue().trim());
         System.out.println("isbn: "+isbn);
         System.out.println("publication year: "+pubYear);
         System.out.println("publisher: "+publisher);
      }
   }
   catch (TransformerException e)
   {
      System.err.println(e);
   }
   catch (XPathExpressionException xpee)
   {
      System.err.println(xpee);

   }
   System.out.println();
}

该方法首先调用ServiceService create(QName serviceName)方法来创建一个Service实例,该实例提供 web 服务的客户端视图。与从 WSDL 文件创建的Service实例相反,其中服务实现类的限定名和其他信息对于Service实例是已知的,由调度客户端创建的Service实例在创建时不需要知道服务;信息将很快提供给这个实例。因此,带有空限定名的QName实例可以传递给create()

一个Dispatch<T>实例在使用前必须绑定到一个特定的端口和端点。因此,doGet()接下来调用Servicevoid addPort(QName portName, String bindingId, String endpointAddress)方法为服务创建一个新端口。(用这种方法创建的端口不包含 WSDL 端口类型信息,只能用于创建Dispatch实例。)传递给portNameQName参数可以包含一个空的限定名。然而,必须通过基于String的绑定标识符来指定适当的绑定。这个例子指定了HTTPBinding.HTTP_BINDING,因为我们通过 HTTP 与 RESTful web 服务通信。此外,目标服务的端点地址必须被指定为 URI,在本例中恰好是[localhost:9902/library](http://localhost:9902/library)

在向Service对象添加一个端口后,doGet()调用前面解释过的createDispatch()。再一次,带有空限定名的QName对象被传递,因为没有 WSDL 来指示端口名。

调用返回的Dispatch<Source>对象的Map<String,Object> getRequestContext()方法(Dispatch从其BindingProvider超接口继承而来)来获取用于初始化请求消息的消息上下文的上下文。doGet()将请求方法动词(GET)和查询字符串(isbn=*isbn*)插入到这个映射中,提供者可以使用这个映射。

此时,doGet()执行Source result = dispatch.invoke(null);,传递null而不是Source对象作为参数,因为提供者的doGet()方法期望以查询字符串的形式接收其数据。如果在调用过程中出现异常,catch 块输出异常信息并退出doGet()。否则,result对象的 XML 内容被转换成一个DOMResult对象,通过XPath表达式对其进行处理以获得结果数据,然后输出。

如果您使用清单 11-24 的doGet()方法运行LibraryClient,并且如果您使用本章前面介绍的与图书相关的相同数据,您将会看到以下输出:

<response>book inserted</response>

<response>book inserted</response>

9781430210450
0201548550

Title: Advanced C+
Author: James O. Coplien
ISBN: 0201548550
Publication Year: 1992
Publisher: Addison Wesley

Title: Beginning Groovy and Grails
Author: Christopher M. Judd
Author: Joseph Faisal Nusairat
Author: James Shingler
ISBN: 9781430210450

Publication Year: 2008
Publisher: Apress

<response>book updated</response>

Title: Advanced C++
Author: James O. Coplien
ISBN: 0201548550
Publication Year: 1992
Publisher: Addison Wesley

<response>book deleted</response>

9781430210450

images 注意为了简化本章对 web 服务的讨论,我一直避免提及线程和线程同步,直到现在。根据 JAX-WS 2.2 规范([download.oracle.com/otndocs/jcp/jaxws-2.2-mrel3-evalu-oth-JSpec/](http://download.oracle.com/otndocs/jcp/jaxws-2.2-mrel3-evalu-oth-JSpec/)),客户端代理实例(从ServicegetPort()方法返回)不能保证是线程安全的。另外,Dispatch实例(从ServicecreateDispatch()方法返回)不是线程安全的。无论哪种情况,当从多个线程访问这些实例时,都必须使用线程同步。

练习

以下练习旨在测试您对 Java web 服务支持的理解:

  1. 创建一个基于 SOAP 的库 web 服务,它识别两个操作,通过方法void addBook(String isbn, String title)String getTitle(String isbn)表达。创建一个LibraryClient应用,调用addBook()然后调用getTitle()来测试这个 web 服务。
  2. 创建一个使用 SAAJ 执行相当于LibraryClient任务的LibraryClientSAAJ应用。使用SOAPMessagewriteTo()方法输出addBookgetTitle操作的每个请求和响应消息。
  3. 由清单 11-11 的Library类描述的 RESTful web 服务是有缺陷的,因为当请求删除一本不存在的书时,doDelete()方法没有通知客户端。如何修改这个方法来报告这种尝试?

总结

Web 服务是基于服务器的应用/软件组件,通过消息交换向客户端公开 Web 资源。公司使用 web 服务是因为它们通过基于自由和开放标准、可维护性、涉及 Web 和灵活性来克服传统中间件的问题。此外,它们帮助公司保护他们在遗留软件上的大量投资。

Web 服务主要分为两类:基于 SOAP 的和 RESTful 的。基于 SOAP 的 web 服务涉及根据 SOAP XML 语言协议格式化的 XML 消息在端点之间的流动,其将网络地址与绑定相结合,其中绑定提供了关于如何将操作接口(其中操作由消息组成)绑定到 SOAP 消息协议的具体细节,以通过网络传递命令、错误代码和其他项目。

基于 SOAP 的 web 服务通常依赖 WSDL 文档来标识服务提供的操作。基于 XML 的 WSDL 文档充当基于 SOAP 的 web 服务及其客户端之间的正式契约,提供与 web 服务交互所需的所有细节。这个文档允许您将消息分组到操作中,将操作分组到接口中。它还允许您为每个接口以及端点地址定义一个绑定。

RESTful web 服务基于万维网的 REST 软件架构风格。REST 的核心部分是 URI 可识别的资源。REST 通过 MIME 类型来标识资源(例如,text/xml)。此外,资源具有由它们的表示捕获的状态。当客户机从 RESTful web 服务请求资源时,服务向客户机发送资源的 MIME 类型表示。客户端使用 HTTP 的POSTGETPUTDELETE动词来检索资源的表示和操作资源——REST 将这些动词视为 API,并将它们映射到数据库 CRUD 操作上。

Java 通过将 API、注释、工具和一个轻量级 HTTP 服务器(用于将您的 web 服务部署到一个简单的 web 服务器并在这个环境中测试它们)合并到其核心中,简化并加速了 web 服务的开发。关键的 API 是 JAX-WS、JAXB 和 SAAJ。重要注释包括WebServiceWebMethodWebServiceProviderBindingServiceMode。还提供了四个工具来简化开发:schemagenwsgenwsimportxjc。轻量级 HTTP 服务器基于 Oracle Java 参考实现的com.sun.net.httpserver包中的类型包。通过 JAX-WS 的Endpoint.publish()方法调用发布的 Web 服务通常会导致启动默认的轻量级 HTTP 服务器,尽管您可以创建自己的 HTTP 服务器,使其上下文对Endpoint.publish()可用,并启动该服务器。

在学习了如何创建和访问自己的基于 SOAP 和 RESTful 的 web 服务,以及如何访问他人创建的基于 SOAP 和 RESTful 的 web 服务之后,您可能会想学习一些高级的 web 服务主题。第十一章部分满足了这一需求,向您展示了如何通过 SAAJ API 访问基于 SOAP 的 web 服务,安装 JAX-WS 处理程序来记录 SOAP 消息流,安装定制的轻量级 HTTP 服务器来执行身份验证,从 RESTful web 服务向客户端发送附件,以及使用提供者调度客户端。

现在来点不同的!第十二章通过向你介绍 Android 并向你展示如何创建一个 Android 应用来结束这本书的非附录部分。

十二、Java 7 遇上安卓

为 Android 设备开发应用如今很流行。也许你想学习如何用 Java 7 开发自己的 Android 应用(尽管你不能使用比 Java 5 更新的 API 和语言特性)。

第十二章简要介绍了应用开发。你首先了解 Android 架构和 Android 应用的架构。然后,您将学习如何安装 Android SDK 和一个平台,这样您就有了开始应用开发的工具和环境。因为 SDK 提供了一个模拟器来模拟 Android 设备,所以接下来您将学习如何创建和启动一个 Android 虚拟设备(AVD ),您可以用它来代替实际的 Android 设备来测试您的应用。最后,向您介绍一个简单的应用,了解如何通过 SDK 创建这个应用,以及如何在 AVD 上安装和运行这个应用。

images 如果你想在读完这一章后了解更多关于 Android 的知识,请查阅马克·墨菲(Apress,2011;ISBN: 978-1-4302-3297-1)。你可能还想看看戴夫·史密斯和杰夫·弗里森的安卓食谱(2011 年出版;ISBN: 978-1-4302-3413-5)。 Android Recipes 向您传授额外的 Android 应用架构基础知识,向您展示如何安装 Eclipse IDE 并使用该 IDE 开发应用,介绍各种应用开发问题的解决方案,向您介绍各种第三方开发工具和 Android NDK,向您展示如何创建自己的库和使用第三方库,并介绍应用设计指南。

探索 Android 和 Android 应用架构

Android 开发人员指南([developer.android.com/guide/index.html](http://developer.android.com/guide/index.html))将 Android 定义为移动设备的软件栈(交付全功能解决方案所需的一组软件子系统)。这个堆栈包括操作系统(Linux 内核的修改版本)、中间件(将低级操作系统连接到高级应用的软件)和关键应用(用 Java 编写),如 web 浏览器(称为浏览器)和联系人管理器(称为联系人)。

Android 提供以下功能:

  • 支持重复使用和替换应用组件的应用框架
  • 蓝牙、EDGE、3G 和 WiFi 支持(取决于硬件)
  • 摄像头、GPS、指南针和加速度计支持(取决于硬件)
  • 针对移动设备优化的 Dalvik 虚拟机
  • GSM 电话支持(取决于硬件)
  • 基于开源 WebKit 引擎的集成浏览器
  • 对常见音频、视频和静止图像格式(MPEG4、H.264、MP3、AAC、AMR、JPG、PNG、GIF)的媒体支持
  • 由定制 2D 图形库支持的优化图形;基于 OpenGL ES 1.0 规范的 3D 图形(可选硬件加速)
  • 用于结构化数据存储的 SQLite

images 注意尽管不是 Android 设备软件栈的一部分,Android 丰富的开发环境(包括一个设备仿真器和一个 Eclipse IDE 插件)也可以被认为是 Android 的一个特性。

Android 应用是用 Java 编写的,只能访问[developer.android.com/reference/packages.html](http://developer.android.com/reference/packages.html)的 API 参考中描述的 Java APIs(以及面向 Android 的第三方 API)。他们不能访问 Java 5 以外的 Java APIs。这个限制影响了 Java 7 的 try-with-resources 语句,该语句基于新的java.lang.AutoCloseable接口和 API 对抑制异常的支持。您不能在 Android 源代码中使用 try-with-resources。

images 注意Android 并不支持所有 Java 5(及之前版本)的 API。例如,Android 不支持抽象窗口工具包(AWT)或 Swing。相反,它提供了一组更小的用户界面 API。

安卓架构

Android 软件栈由顶部的应用、中间的中间件(由应用框架、库和 Android 运行时组成)和底部的带有各种驱动程序的 Linux 内核组成。图 12-1 显示了这种分层架构。

images

图 12-1。 Android 的分层架构由几大部分组成。

用户关心应用,Android 附带了各种有用的核心应用,包括浏览器、联系人和电话。所有的 app 都是用 Java 写的。应用构成了 Android 架构的顶层。

在应用层的正下方是应用框架,这是一组用于创建应用的高级构建模块。应用框架预装在 Android 设备上,由以下组件组成:

  • 活动管理器:这个组件提供了一个应用的生命周期,并维护一个共享的活动堆栈,用于在应用内部和应用之间导航。(我将在本章后面介绍活动时讨论这两个概念。)
  • 内容提供者:这些组件封装了可以在应用之间共享的数据(例如,浏览器应用的书签)。
  • 位置管理器(Location Manager):这个组件使得 Android 设备能够知道自己的物理位置。
  • 通知管理器:这个组件让一个应用通知用户一个重要的事件(例如,一条消息的到达),而不打断用户当前正在做的事情。
  • 包管理器:这个组件让一个应用了解当前安装在设备上的其他应用包。(本章稍后将讨论应用包。)
  • 资源管理器(Resource Manager):这个组件让一个应用访问它的应用资源,这个主题我将在本章后面讨论。
  • 电话管理器:这个组件让应用了解设备的电话服务。它还处理拨打和接听电话。
  • 视图系统:该组件管理用户界面元素和面向用户界面的事件生成。(我将在本章后面简要讨论这些主题。)
  • 窗口管理器(Window Manager):这个组件将屏幕空间组织到窗口中,分配绘图表面,并执行其他与窗口相关的任务。

应用框架的组件依赖一组 C/C++库来执行它们的工作。开发人员通过框架 API 与以下库进行交互:

  • FreeType :这个库支持位图和矢量字体渲染。
  • libc :这个库是标准 C 系统库的 BSD 派生实现,针对基于嵌入式 Linux 的设备进行了调整。
  • LibWebCore :这个库提供了一个现代化的快速网络浏览器引擎,支持 Android 浏览器和嵌入式网络视图。它基于 WebKit ( [en.wikipedia.org/wiki/WebKit](http://en.wikipedia.org/wiki/WebKit)),也用于谷歌 Chrome 和苹果 Safari 浏览器。
  • 媒体框架:这些基于 PacketVideo 的 OpenCORE 的库,支持许多流行的音频和视频格式的回放和录制,以及处理静态图像文件。支持的格式包括 MPEG4、H.264、MP3、AAC、AMR、JPEG 和 PNG。
  • OpenGL | ES :这些 3D 图形库提供了基于 OpenGL ES 1.0 APIs 的 OpenGL 实现。他们使用硬件 3D 加速(如果可用)或内置的(高度优化的)3D 软件光栅化器。
  • SGL:这个库提供了底层的 2D 图形引擎。
  • SQLite :这个库提供了一个强大的轻量级关系数据库引擎,所有应用都可以使用,Mozilla Firefox 和苹果的 iPhone 也使用这个引擎进行持久存储。
  • SSL:这个库为网络通信提供了基于安全套接字层的安全性。
  • Surface Manager :这个库管理对显示子系统的访问,并无缝合成来自多个应用的 2D 和 3D 图形层。

Android 提供了一个运行时环境,由核心库(实现 Apache Harmony Java 5 实现的子集)和 Dalvik 虚拟机(一个基于处理器寄存器而不是基于堆栈的非 Java 虚拟机)组成。

images 谷歌的丹·博恩施泰因创造了达尔维克,并以他的一些祖先居住的冰岛渔村命名了这个虚拟机。

每个 Android 应用默认运行在自己的 Linux 进程中,该进程托管一个 Dalvik 实例。该虚拟机的设计使得设备可以高效地运行多个虚拟机。这种效率在很大程度上是由于 Dalvik 执行基于 Dalvik 可执行文件(DEX)的文件——DEX 是一种针对最小内存占用而优化的格式。

images 注意 Android 在应用的任何部分需要执行时启动一个进程,在不再需要和其他应用需要环境资源时关闭该进程。

也许您想知道如何让非 Java 虚拟机运行 Java 代码。答案是 Dalvik 不运行 Java 代码。相反,Android 将编译后的 Java 类文件转换成 DEX 格式,Dalvik 执行的就是这些结果代码。

最后,库和 Android 运行时依赖于 Linux 内核(2.6 版。用于底层核心服务,如线程、低级内存管理、网络堆栈、进程管理和驱动程序模型。此外,内核充当硬件和软件堆栈其余部分之间的抽象层。

安卓安全模式

Android 的架构包括一个安全模型,可以防止应用执行被认为对其他应用、Linux 或用户有害的操作。这种安全模型主要基于通过标准 Linux 特性(如用户和组 id)的进程级实施,将进程放在安全沙箱中。

默认情况下,沙盒会阻止应用读取或写入用户的私人数据(例如,联系人或电子邮件),读取或写入另一个应用的文件,执行网络访问,保持设备唤醒,访问摄像头,等等。需要访问网络或执行其他敏感操作的应用必须首先获得许可。

Android 以各种方式处理权限请求,通常是根据证书自动允许或拒绝请求,或者提示用户授予或撤销权限。应用所需的权限在应用的清单文件中声明(将在本章后面讨论),以便在安装应用时 Android 知道它们。这些权限不会随后更改。

App 架构

Android 应用的架构不同于在桌面上运行的应用的架构。应用架构基于通过意图相互通信的组件,由清单描述,并可能使用应用资源。总的来说,这些项目存储在一个应用包中。

组件

Android 应用是在 Linux 进程中运行并由 Android 管理的组件(活动、广播接收器、内容提供者和服务)的集合。这些组件共享一组环境资源,包括数据库、首选项、文件系统和 Linux 进程。

images 注意并非所有这些组件都需要出现在一个应用中。例如,一个应用可能只包含活动,而另一个应用可能包含活动和服务。

这种面向组件的架构允许一个应用重用其他应用的组件,前提是这些其他应用允许重用它们的组件。组件重用减少了整体内存占用,这对于内存有限的设备非常重要。

例如,假设您正在创建一个绘图应用,让用户从调色板中选择一种颜色,并假设另一个应用已经开发了一个合适的颜色选择器,并允许重用该组件。在这种情况下,绘图应用可以调用其他应用的颜色选择器,让用户选择一种颜色,而不是提供自己的颜色选择器。绘图应用不包含其他应用的颜色选择器,甚至也不链接到其他应用。相反,它会在需要时启动其他应用的颜色选择器组件。

当需要应用的任何部分(例如,前面提到的颜色选择器)时,Android 启动一个进程,并为该部分实例化 Java 对象。这就是为什么 Android 的应用没有单一的入口点(例如,没有 C 风格的main()功能)。相反,应用使用根据需要实例化和运行的组件。

通过意图沟通

活动、广播接收器和服务通过意图相互通信,意图是描述要执行的操作(例如,发送电子邮件或选择照片)的消息,或者(在广播的情况下)提供已经发生的外部事件的描述(例如,设备的摄像头被激活)和正在被宣布的消息。

因为 Android 中几乎所有的东西都包含意图,所以有很多机会用你自己的组件替换现有的组件。例如,Android 提供发送电子邮件的意图。您的应用可以发送该意图来激活标准邮件应用,或者它可以注册一个活动(稍后讨论)来响应“发送电子邮件”意图,有效地用它自己的活动替换标准邮件应用。

这些消息被实现为android.content.Intent类的实例。一个Intent对象根据以下项目的某种组合来描述一条消息:

  • Action :命名要执行的动作的字符串,或者在广播意图的情况下,命名已经发生并正在报告的动作。动作由Intent常量描述,如ACTION_CALL(发起电话呼叫)、ACTION_EDIT(显示数据供用户编辑)和ACTION_MAIN(启动作为初始活动)。您还可以定义自己的操作字符串来激活应用中的组件。这些字符串应该包括应用包作为前缀(例如,"com.example.project.SELECT_COLOR")。
  • 类别:一个字符串,提供关于应该处理意图的组件种类的附加信息。例如,CATEGORY_LAUNCHER表示调用活动应该作为顶级应用出现在设备的应用启动器中。(app launcher 将在本章稍后简要讨论。)
  • 组件名:一个字符串,指定用于 intent 的组件类的完全限定名(包加名称)。组件名称是可选的。设置后,Intent对象被传递给指定类的一个实例。未设置时,Android 使用Intent对象中的其他信息来定位合适的目标。
  • 数据:要操作的数据的统一资源标识符(URI)(例如,联系人数据库中的个人记录)。
  • Extras :一组键值对,提供应该交付给处理意图的组件的附加信息。例如,给定一个发送电子邮件的操作,该信息可以包括邮件的主题、正文等。
  • 标志:位值,指示 Android 如何启动一个活动(例如,该活动应属于哪个任务——任务将在本章稍后讨论)以及如何在启动后处理该活动(例如,该活动是否可被视为最近的活动)。标志由Intent类中的常数表示;例如,FLAG_ACTIVITY_NEW_TASK指定该活动将成为该历史堆栈上一个新任务的开始—该历史堆栈将在本章稍后讨论。
  • Type :意向数据的多用途互联网邮件扩展(MIME)类型。通常情况下,Android 会从数据中推断出一种类型。通过指定类型,可以禁用该推断。

意图可以分为显性和隐性。一个显式意图通过名称指定目标组件(前面提到的组件名称项被赋值)。因为其他应用的开发人员通常不知道组件名称,所以显式意图通常用于应用内部消息(例如,一个活动启动位于同一应用内的另一个活动)。Android 向指定目标类的实例传递了一个明确的意图。只有Intent对象的组件名对确定哪个组件应该得到意图有影响。

一个隐含意图没有命名一个目标(组件名没有赋值)。隐式意图通常用于启动其他应用中的组件。Android 搜索最佳组件(执行请求动作的单个活动或服务)或组件(响应广播通知的一组广播接收器)来处理隐含的意图。在搜索过程中,Android 将Intent对象的内容与意图过滤器进行比较,意图过滤器是与可能接收意图的组件相关联的清单信息。

过滤器通告组件的能力,并且只识别组件可以处理的那些意图。它们向组件开放了接收广告类型的隐含意图的可能性。当一个组件没有意图过滤器时,它只能接收明确的意图。相比之下,带有过滤器的组件可以接收显式和隐式意图。Android 在将意图与意图过滤器进行比较时会参考Intent对象的动作、类别、数据和类型。它不考虑额外费用和旗帜。

活动

活动是提供用户界面的组件,以便用户可以与应用进行交互。例如,Android 的联系人应用包括输入新联系人的活动,其电话应用包括拨打电话号码的活动,其计算器应用包括执行基本计算的活动(见图 12-2 )。

images

图 12-2。安卓计算器应用的主要活动是让用户进行基本的计算。

虽然一个应用可以包含单个活动,但更常见的是应用包含多个活动。例如,Calculator 还包括一个“高级面板”活动,让用户计算平方根、执行三角学以及执行其他高级数学运算。

images 注意因为活动是使用最频繁的组件,所以我对它们的讨论比广播接收器、内容提供者和服务更详细。查看 Android 菜谱,了解这些其他组件类别的详细报道。

活动由android.app.Activity类的子类描述,它是抽象android.content.Context类的间接子类。

images Context是一个抽象类,其方法让应用访问关于其环境的全局信息(例如,其应用资源),并允许应用执行上下文操作,例如启动活动和服务、广播意图和打开私人文件。

Activity子类覆盖 Android 在活动生命周期中调用的各种Activity 生命周期回调方法。例如,清单 12-1 的SimpleActivity类,它被放在一个包中,因为 Android 要求一个应用的组件被存储在一个唯一的包中,它扩展了Activity,也覆盖了void onCreate(Bundle bundle)void onDestroy()生命周期回调方法。

清单 12-1。骨骼活动

package ca.tutortutor.simpleapp;

import android.app.Activity;

import android.os.Bundle;

public class SimpleActivity extends Activity
{
   @Override
   public void onCreate(Bundle savedInstanceState)
   {
      super.onCreate(savedInstanceState); // Always call superclass method first.
      System.out.println("oncreate(bundle) called");
   }
   @Override
   public void onDestroy()
   {
      super.onDestroy(); // Always call superclass method first.
      System.out.println("ondestroy() called");
   }
}

SimpleActivity的覆盖onCreate(Bundle)onDestroy()方法首先调用它们的超类对应物,这是在覆盖void onStart()void onRestart()void onResume()void onPause()void onStop()生命周期回调方法时必须遵循的模式。

  • 首次创建活动时会调用onCreate(Bundle)。此方法用于创建活动的用户界面,根据需要创建后台线程,以及执行其他全局初始化。当状态被捕获时,onCreate()被传递一个包含活动先前状态的android.os.Bundle对象;否则,传递空引用。Android 总是在调用onCreate(Bundle)之后调用onStart()方法。
  • onStart()在活动对用户可见之前被调用。Android 在活动来到前台时调用onStart()后调用onResume()方法,在活动变为隐藏时调用onStart()后调用onStop()方法。
  • onRestart()在活动停止之后,再次开始之前被调用。Android 总是在调用onRestart()之后调用onStart()
  • 在活动开始与用户交互之前调用onResume()。此时,活动获得焦点,用户输入指向该活动。Android 总是在调用onResume()之后调用onPause()方法,但只是在活动必须暂停的时候。
  • 当 Android 将要恢复另一个活动时,调用onPause()。此方法通常用于保存未保存的更改、停止可能消耗处理器周期的动画等。它应该很快执行它的工作,因为下一个活动在它返回之前不会恢复。Android 在活动开始与用户交互时调用onPause()后调用onResume(),在活动变得对用户不可见时调用onStop()
  • 当活动对用户不再可见时,调用onStop()。这可能是因为该活动正在被销毁,或者因为另一个活动(现有活动或新活动)已经恢复并覆盖了该活动。Android 在调用onStop()之后调用onRestart(),当活动即将回来与用户交互时,调用onDestroy()方法,当活动即将离去时。
  • onDestroy()在活动被销毁之前被调用,除非内存紧张,Android 强制杀死活动的进程。在这种情况下,onDestroy()永远不会被调用。如果onDestroy()被调用,这将是该活动收到的最后一个调用。

images 注意 Android 可以在onPause()onStop()onDestroy()返回后随时杀死托管活动的进程。从onPause()返回到onResume()被调用,活动处于可终止状态。在onPause()返回之前,该活动不会再次被取消。

这七种方法定义了活动的整个生命周期,并描述了以下三个嵌套循环:

  • 活动的整个生命周期被定义为从第一次调用onCreate(Bundle)到最后一次调用onDestroy()的所有内容。一个活动在onCreate(Bundle)执行其所有“全局”状态的初始设置,并在onDestroy()释放所有剩余的环境资源。例如,当活动有一个线程在后台运行以从网络下载数据时,它可能会在onCreate(Bundle)中创建该线程,并在onDestroy()中停止该线程。
  • 活动的可见生存期被定义为从调用onStart()到相应调用onStop()的所有内容。在此期间,用户可以在屏幕上看到活动,尽管它可能不在前台并与用户交互。在这两种方法之间,活动可以维护向用户显示自身所需的资源。例如,它可以在onStart()中注册一个广播接收器,以监视影响其用户界面的变化,并在用户看不到活动显示的内容时在onStop()中注销该对象。随着活动在对用户可见和隐藏之间交替,可以多次调用onStart()onStop()方法。
  • 活动的前台生存期被定义为从对onResume()的调用到对onPause()的相应调用的所有内容。在此期间,该活动位于屏幕上所有其他活动的前面,并与用户进行交互。活动可以频繁地在恢复和暂停状态之间转换;例如,onPause()在设备进入睡眠或新活动开始时被调用,而onResume()在活动结果或新意图被传递时被调用。这两种方法中的代码应该相当轻量级。

images 注意每个生命周期回调方法都是一个钩子,活动可以覆盖它来执行适当的工作。当活动对象第一次被实例化时,所有活动都必须实现onCreate(Bundle)来执行初始设置。许多活动还实现了onPause()来提交数据更改,或者准备停止与用户的交互。

图 12-3 用这七种方法说明了一个活动的生命周期。

images

图 12-3。一项活动的生命周期表明,不能保证onDestroy()会被调用。

因为onDestroy()可能不会被调用,所以你不应该指望使用这个方法作为保存数据的地方。例如,当一个活动正在编辑内容提供商的数据时,这些编辑通常应该在onPause()中提交。

相比之下,onDestroy()通常被实现来释放与活动相关联的环境资源(例如,线程),以便被破坏的活动不会在其应用的其余部分仍在运行时留下这样的东西。

图 12-3 显示一个活动是通过调用startActivity()开始的。更具体地说,活动是通过创建一个描述显式或隐式意图的Intent对象,并将该对象传递给Contextvoid startActivity(Intent intent)方法(启动一个新活动;完成时不返回任何结果)。

或者,可以通过调用Activityvoid startActivityForResult(Intent intent, int requestCode)方法来启动活动。指定的int结果作为参数返回给Activityvoid onActivityResult(int requestCode, int resultCode, Intent data)回调方法。

images 注意响应活动可以通过调用ActivityIntent getIntent()方法来查看导致其启动的初始意图。Android 调用活动的void onNewIntent(Intent intent)方法(也位于Activity类中)将任何后续意图传递给活动。

清单 12-1 的 package 语句隐含了一个名为SimpleApp的 app。除了作为其主要活动的SimpleActivity之外,让我们假设这个应用包含一个描述查看 JPEG 图像的活动的SimpleActivity2类。假设你想从SimpleActivityonCreate(Bundle)方法开始SimpleActivity2。以下示例向您展示了如何完成此任务:

Intent intent = new Intent(SimpleActivity.this, SimpleActivity2.class);
SimpleActivity.this.startActivity(intent);

第一行创建一个描述明确意图的Intent对象。它通过将当前SimpleActivity实例的引用和SimpleActivity2java.lang.Class实例传递给Intent(Context packageContext, Class<?> clazz)构造函数来初始化这个对象。

第二行将这个Intent对象传递给startActivity(Intent),后者负责启动由SimpleActivity2.class描述的活动。如果startActivity(Intent)无法找到指定的活动(这不应该发生),它将抛出一个android.content.ActivityNotFoundException实例。

以下示例显示了如何隐式启动SimpleActivity2:

Intent intent = new Intent();
intent.setAction(Intent.ACTION_VIEW); // Use Intent constants instead of literal ...
intent.setType("image/jpeg");
intent.addCategory(Intent.CATEGORY_DEFAULT); // ... strings to reduce errors.
SimpleActivity.this.startActivity(intent);

前四行创建一个描述隐含意图的Intent对象。传递给IntentIntent setAction(String action)Intent setType(String type)Intent addCategory(String category)方法的值指定了意图的动作、MIME 类型和类别。它们帮助 Android 将SimpleActivity2识别为要开始的活动。

活动、任务和活动堆栈

Android 将一系列相关活动称为一个任务,并提供一个活动堆栈(也称为历史堆栈后台堆栈)来记住这个序列。启动任务的活动是推入堆栈的初始活动,称为根活动。该活动通常是用户通过设备的应用启动器选择的活动。当前正在运行的活动位于堆栈的顶部。

当当前活动启动另一个活动时,新活动被推送到堆栈上并获得焦点(成为正在运行的活动)。前一个活动保留在堆栈上,但已停止。当活动停止时,系统保留其用户界面的当前状态。

当用户按下设备的 BACK 键时,当前活动从堆栈中弹出(活动被销毁),之前的活动作为正在运行的活动恢复操作(其用户界面的先前状态被恢复)。

堆栈中的活动不会被重新排列,只会从堆栈中推出和弹出。当当前活动启动时,活动被推到堆栈上,当用户通过 BACK 键离开时,活动弹出堆栈。

每次用户按 BACK,一个活动就会弹出堆栈,显示前一个活动。这一直持续到用户返回到主屏幕或者任务开始时正在运行的任何活动。当所有活动都从堆栈中移除时,任务就不再存在。

查看 Google 在线 Android 文档中的“任务和后台堆栈”部分,了解更多关于活动和任务的信息:[developer.android.com/guide/topics/fundamentals/tasks-and-back-stack.html](http://developer.android.com/guide/topics/fundamentals/tasks-and-back-stack.html)

广播接收机

广播接收器是接收广播并对广播做出反应的组件。许多广播源自系统代码;例如,时区已更改或电池电量低的通知。

应用也可以发起广播。例如,一个应用可能希望让其他应用知道一些数据已经从网络下载到设备,现在可供他们使用。

images 抽象android.content.BroadcastReceiver类实现广播接收机。

内容提供商

内容提供商是一个组件,它使一个应用的特定数据集可供其他应用使用。数据可以存储在 Android 文件系统、SQLite 数据库或任何其他有意义的方式中。

内容提供者比直接访问原始数据更可取,因为它们将组件代码从原始数据格式中分离出来。这种分离防止了格式改变时的代码中断。

images 抽象android.content.ContentProvider类实现内容提供者。

服务

服务是在后台无限期运行的组件,它不提供用户界面。与活动一样,服务在流程的主线程上运行;它必须产生另一个线程来执行耗时的操作。服务分为本地服务和远程服务:

  • 一个本地服务与应用的其余部分在相同的进程中运行。这样的服务使得实现后台任务变得容易。
  • 一个远程服务在一个单独的进程中运行。这种服务允许您执行进程间通信。

images 注意服务不是一个独立的进程,尽管它可以被指定在一个独立的进程中运行。此外,服务不是线程。相反,一项服务让应用告诉 Android 它想在后台做的事情(即使用户没有直接与应用交互),并让应用向其他应用公开它的一些功能。

考虑一个服务,它通过一个活动播放音乐来响应用户的音乐选择。用户通过该活动选择要播放的歌曲,并且响应于该选择启动服务。使用服务来播放音乐的基本原理是,用户希望即使在启动音乐的活动离开屏幕后,音乐也能继续播放。

该服务在另一个线程上播放音乐,以防止出现应用不响应对话框(见图 12-4 )。

images

图 12-4。可怕的应用不响应对话框可能会导致用户卸载应用。

images 抽象android.app.Service类实现服务。

清单

Android 通过检查应用的 XML 结构清单文件AndroidManifest.xml来了解应用的各种组件(以及更多)。例如,清单 12-2 展示了这个文件如何声明清单 12-1 的活动组件。

清单 12-2。 SimpleApp的货单文件

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="ca.tutortutor.simpleapp">
   <application android:label="@string/app_name" android:icon="@drawable/icon">
      <activity android:name=".simpleactivity" android:label="@string/app_name">

         <intent-filter>
            <action android:name="android.intent.action.main" />
            <category android:name="android.intent.category.launcher" />
         </intent-filter>
      </activity>
      <!-- ... -->
   </application>
</manifest>

清单 12-2 以<?xml version="1.0" encoding="utf-8"?>序言开始,它将该文件标识为 XML 1.0 版文件,其内容根据 UTF-8 编码标准进行编码。(第十章向您介绍 XML。)

清单 12-2 接下来呈现的是manifest元素,这是这个 XML 文档的根元素:android标识 Android 名称空间,package标识应用的 Java 包——每个应用必须有自己的 Java 包,在本例中是ca.tutortutor.simpleapp。可以指定附加属性。例如,当您想要识别版本信息时,您可以指定versionCodeversionName属性。

嵌套在manifest中的是application,它是 app 组件元素的父元素。它的labelicon属性指的是 Android 设备显示的代表应用的标签和图标应用资源,这些资源作为开始标签没有指定这些属性的单个组件的默认值。(我稍后将讨论应用资源。)

images 注意应用资源由前缀@标识,后跟类别名称(如stringdrawable)、/和应用资源 ID(如app_nameicon)。

嵌套在application中的是一个描述活动组件的activity元素。属性name标识了一个实现活动的类(SimpleActivity)。这个名字以句点字符开头,暗示它与ca.tutortutor.simpleapp相关。

images 注意在命令行创建AndroidManifest.xml时,句点不存在。然而,当这个文件在 Eclipse 中创建时,这个字符是存在的。无论如何,SimpleActivity是相对于<manifest>package值(ca.tutortutor.simpleapp)。

activity元素可以用自己特定于组件的labelicon属性覆盖applicationlabelicon属性。当任一属性不存在时,activity继承applicationlabelicon属性值。

嵌套在activity中的是intent-filter。该元素声明由封闭元素描述的组件的功能。例如,它通过嵌套的actioncategory元素声明了SimpleActivity组件的功能:

  • action标识要执行的动作。例如,这个元素的name属性可以被赋值为"android.intent.action.main",以将活动标识为应用的入口点(用户启动应用时运行的第一个活动)。
  • category标识一个组件类别。这个标签的name属性被赋予"android.intent.category.launcher"来标识需要在应用启动器中显示的活动(稍后讨论)。

images 注意其他组件的声明类似:广播接收器通过receiver元素声明,内容提供者通过provider元素声明,服务通过service元素声明。除了可以在运行时创建的广播接收器之外,清单中没有声明的组件不是由 Android 创建的。

注释标签表明一个清单可以定义多个组件。比如我在讨论活动的时候参考了一个SimpleActivity2类。在开始这个活动(显式或隐式)之前,您需要在清单中引入一个activity元素。

考虑下面的activity元素:

<activity android:name=".simpleactivity2" ...>
    <intent-filter>
        <action android:name="android.intent.action.view" />
        <data android:mimeType="image/jpeg" />
        <category android:name="android.intent.category.default" />
    </intent-filter>
</activity>

SimpleActivity2intent-filter元素帮助 Android 确定当Intent对象的值匹配以下标签属性值时,该活动将被启动:

  • <action>name属性被赋予"android.intent.action.view"
  • <data>mimeType属性被赋予了"image/jpeg" MIME 类型。
  • <category>name属性被分配给"android.intent.category.default",以允许在没有明确指定其组件的情况下启动活动。

images 注意data元素描述了意图操作的数据。它的mimeType属性标识数据的 MIME 类型。可以指定附加属性。例如,您可以指定path来标识数据的位置 URI。

AndroidManifest.xml可能包含额外的信息,例如命名应用需要链接的任何库(除了默认的 Android 库),以及识别所有应用对其他应用强制的权限(通过permission元素),例如控制谁可以启动应用的活动。

此外,清单可能包含uses-permission元素来标识应用需要的权限。例如,一个需要使用摄像头的应用会指定以下元素:<uses-permission android:name="android.permission.camera" />

images 注意 uses-permission元素嵌套在manifest元素中——它们与application元素出现在同一级别。

在应用安装时,应用请求的权限(通过uses-permission)由 Android 的包安装程序授予,基于对声明这些权限的应用的数字签名和/或与用户的交互的检查。

应用运行时,不会对用户进行任何检查。它在安装时被授予了特定的权限,可以根据需要使用该功能,或者权限未被授予,任何使用该功能的尝试都将失败,而不会提示用户。

应用资源

除了为其组件共享一组环境资源(例如,数据库、首选项、文件系统、线程和 Linux 进程)之外,应用还可以拥有自己的应用资源:属性动画、补间动画、颜色状态列表、可绘制图形、布局、菜单、原始文件、简单值(例如,字符串)和任意 XML 文件。

images

Android 要求应用将其应用资源文件存储在应用的res目录的表 12-1 的子目录中(以及它们的子目录,如果合适的话)。

images

images

从 Android 1.6 开始,Android 首先在resdrawable-hdpidrawable-mdpidrawable-ldpi子目录中寻找可绘制对象,具体取决于设备的屏幕分辨率是高(hdpi)、中(mdpi)还是低(ldpi)。如果在那里没有找到 drawable,它就在resdrawable子目录中查找。

当我在本章的后面向你介绍Java7MeetsAndroid应用时,我会有更多关于应用资源的内容要说。

images 要了解更多关于应用资源的信息,请查看谷歌的“应用资源”指南([developer.android.com/guide/topics/resources/index.html](http://developer.android.com/guide/topics/resources/index.html))。

应用包

Android 应用是用 Java 编写的。为应用组件编译的 Java 代码被进一步转换成 Dalvik 的 DEX 格式。生成的代码文件以及任何其他所需的数据和应用资源随后被捆绑到一个应用包(APK)中,该文件由后缀.apk标识。

APK 不是一个应用,但用于分发应用的至少一部分并将其安装在移动设备上。它不是一个应用,因为它的组件可能会重用另一个 APK 的组件,而且(在这种情况下)不是所有的应用都驻留在一个 APK 中。此外,它可能只分发应用的一部分。然而,通常称一个 APK 代表一个应用。

一个 APK 必须用一个证书来签名(这个证书标识了这个应用的作者),证书的私钥由它的开发者持有。证书不需要由证书颁发机构签名。相反,Android 允许用自签名证书对 apk 进行签名,这很典型。(安卓食谱讨论 APK 签约。)

apk、用户 id 和安全性

安装在 Android 设备上的每个 APK 都有自己唯一的 Linux 用户 id,只要 APK 驻留在该设备上,这个用户 ID 就保持不变。

因为安全实施发生在进程级,包含在任何两个 APK 中的代码通常不能在同一个进程中运行,因为每个 APK 的代码需要作为不同的 Linux 用户运行。然而,通过在每个 APK 的AndroidManifest.xml文件中给<manifest>标签的sharedUserId属性分配相同名称的用户 ID,可以让两个 apk 中的代码在同一个进程中运行。当你进行这些分配时,你告诉 Android 这两个包将被视为相同的应用,具有相同的用户 id 和文件权限。

为了保持安全性,只有用相同签名签名的两个 apk(并且在其清单中请求相同的sharedUserId值)将被给予相同的用户 ID。

安装 Android SDK 和 Android 平台

现在你已经对 Android 和 Android 应用架构有了基本的了解,你可能想要创建一个应用。但是,在安装 Android SDK 和 Android 平台之前,您不能这样做。本节将向您展示如何完成这些任务。

访问系统要求

Google 为 Windows、基于英特尔的 Mac OS X 和 Linux (i386)操作系统提供了 Android SDK 分发文件。在下载和安装该文件之前,您必须了解 SDK 的要求。当您的开发平台不满足这些要求时,您不能使用 SDK。

Android SDK 支持以下操作系统:

  • Windows XP (32 位)、Vista (32 位或 64 位)或 Windows 7 (32 位或 64 位)
  • Mac OS X 10.5.8 或更高版本(仅限 x86)
  • Linux(在 Ubuntu Linux,Lucid Lynx 上测试):需要 GNU C 库(glibc ) 2.11 或更高版本。64 位发行版必须能够运行 32 位应用。要了解如何添加对 32 位应用的支持,请参阅位于[developer.android.com/sdk/installing.html#troubleshooting](http://developer.android.com/sdk/installing.html#troubleshooting)的 Ubuntu Linux 安装说明。

你会很快发现 Android SDK 被组织成各种组件:SDK 工具、SDK 平台工具、不同版本的 Android 平台(也称为 Android 软件栈)、SDK 插件、Windows 的 USB 驱动程序、示例和离线文档。每个组件都需要一个最小量的磁盘存储空间;所需空间总量取决于您选择安装的组件:

  • SDK 工具:SDK 的工具需要大约 35MB 的磁盘存储空间,必须安装。
  • SDK 平台工具:SDK 的平台工具需要大约 6MB 的磁盘存储空间,必须安装。
  • Android 平台:每个 Android 平台对应一个特定版本的 Android,需要大约 150MB 的磁盘存储空间。必须至少安装一个 Android 平台。
  • SDK 插件:每个可选的 SDK 插件(例如 Google APIs 或第三方供应商的 API 库)需要大约 100MB 的磁盘存储空间。
  • 用于 Windows 的 USB 驱动程序:用于 Windows 平台的可选 USB 驱动程序需要大约 10MB 的磁盘存储空间。当你在 Mac OS X 或 Linux 上开发时,你不需要安装 USB 驱动程序。
  • 样例:每个 Android 平台的可选 app 样例都需要大约 10MB 的磁盘存储空间。
  • 离线文档:无需在线访问 Android 文档,您可以选择下载文档,这样即使没有连接到互联网也可以查看。脱机文档需要大约 250MB 的磁盘存储空间。

最后,您应该确保安装了以下附加软件:

  • JDK 5、JDK 6 或 JDK 7:你需要安装这些 Java 开发工具包(JDK)中的一个来编译 Java 代码。仅仅安装 Java 运行时环境(JRE)是不够的。此外,不能使用依赖于比 Java 5 更新的 API 的 Java 7 语言特性;try-with-resources 语句不可用。
  • Apache Ant :需要安装 Ant 1.8 或更高版本,这样才能构建 Android 项目。

images 注意当您的开发平台上已经安装了一个 JDK 时,花一点时间来确保它满足前面列出的版本需求(5、6 或 7)。一些 Linux 发行版可能包含 JDK 1.4,Android 开发不支持该版本。此外,Gnu 编译器 Java 版也不受支持。

安装 Android SDK

将您的浏览器指向[developer.android.com/sdk/index.html](http://developer.android.com/sdk/index.html)并下载适用于您平台的 Android SDK 的当前版本。例如,您可以下载android-sdk_r12-windows.zip (Windows)、android-sdk_r12-mac_x86.zip (Mac OS X [Intel])和android-sdk_r12-linux_x86.tgz (Linux [i386])中的一个来安装 Android SDK Release 12。(我在这一章中关注的是 Release 12,因为在我写作的时候它是最新的;本书出版时,可能会有新的版本。)

images Windows 开发者有下载运行installer_r12-windows.exe的选项。Google 建议您使用这个工具,它可以自动完成大部分安装过程。

比如你运行 Windows,下载android-sdk_r12-windows.zip。在解压缩这个文件之后,将解压缩的android-sdk-windows主目录移动到文件系统中一个方便的位置;例如,您可能会将未归档的C:\unzipped\android-sdk_r12-windows\android-sdk-windows主目录移动到 C:驱动器上的根目录,从而产生C:\android-sdk-windows

images 注意要完成安装,请将tools子目录添加到您的PATH环境变量中,以便您可以从文件系统中的任何位置访问 SDK 的命令行工具。

android-sdk-windows的后续检查显示,该主目录包含以下子目录和文件:

  • add-ons :这个最初为空的目录存储了来自谷歌和其他厂商的插件;例如,Google APIs 附加组件就存储在这里。
  • platforms :这个最初为空的目录将 Android 平台存储在单独的子目录中。例如,Android 2.3 将存储在一个platforms子目录中,而 Android 2.2 将存储在另一个platforms子目录中。
  • tools :这个目录包含一组平台无关的开发和剖析工具。该目录中的工具可能会随时更新,与 Android 平台版本无关。
  • SDK Manager.exe :一个启动 Android SDK 和 AVD 管理器工具的特殊工具,您可以使用它向您的 SDK 添加组件。
  • SDK Readme.txt :这个文本文件欢迎您使用 Android SDK,并讨论安装 Android 平台。

tools目录包含各种有用的工具,包括:

  • android :创建和更新 Android 项目;用新的平台、插件和文档更新 Android SDK 并且创建、删除和查看avd(描述虚拟设备的描述符)。
  • emulator :运行完整的 Android 软件堆栈,下至内核级别,包括一组您可以访问的预装应用(例如浏览器)。emulator工具启动 AVD
  • sqlite3 :管理 Android 应用创建的 SQLite 数据库。
  • zipalign :对 apk 执行归档对齐优化。

安装安卓平台

安装 Android SDK 不足以开发 Android 应用;您还必须安装至少一个 Android 平台。您可以通过SDK Manager工具完成这项任务。

运行SDK Manager。该工具显示 Android SDK 和 AVD 管理器对话框,随后是刷新源代码和选择要安装的软件包对话框。

images 注意你也可以使用android工具来显示 Android SDK 和 AVD。通过在命令行中单独指定android来完成这项任务。

Android SDK 和 AVD 管理器对话框标识虚拟设备、已安装的软件包和可用的软件包。它还允许您配置代理服务器和其他设置。

首次出现此对话框时,对话框右侧列表中的虚拟设备条目会突出显示,列表右侧的窗格会标识所有已创建的 avd(此列表可能为空)。

显示该对话框后,SDK Manager扫描 Google 的服务器,寻找可安装的组件包。“刷新源”对话框显示其进度。

SDK Manager完成扫描(这可能需要几分钟)后,它会显示选择要安装的软件包对话框(参见图 12-5 )让您选择要安装的 SDK 组件。(如果你已经安装了 Android SDK Release 12,并且之前没有安装 Android,那么唯一安装的组件是 Android SDK Tools,revision 12。)

images

图 12-5。软件包列表标识了那些可以安装的软件包。

选择要安装的软件包对话框显示了一个软件包列表,它标识了那些可以安装的软件包。它会在已接受安装的软件包旁边显示复选标记,并在尚未接受的软件包旁边显示问号。

对于突出显示的软件包,“软件包说明和许可证”提供了软件包说明、依赖于正在安装的此软件包的其他软件包列表、有关包含该软件包的归档文件的信息以及其他信息。此外,您可以选择一个单选按钮来接受或拒绝该包。如果您拒绝突出显示的包,X 图标将替换复选标记或问号图标。

images 注意在某些情况下,一个 SDK 组件可能需要另一个组件或 SDK 工具的特定最低版本。除了记录这些依赖关系的包描述&许可证之外,当有需要解决的依赖关系时,开发工具会通知您调试警告。

Android 平台 3.0 及以上版本是指面向平板电脑的 Android。3.0 以下版本指的是面向智能手机的 Android。因为本章重点介绍 Android 2.3.3,所以您需要安装的唯一软件包是 Android SDK 平台-工具,修订版 6 和 SDK 平台 Android 2.3.3,API 10,修订版 2。通过单击相应窗格上的拒绝选项单选按钮(或双击列表条目),可以取消选中所有其他选中的包条目。

images 注意如果你计划开发可以在旧版本 Android 设备上运行的应用,你可能想在旧版本旁边留下复选标记。然而,此时没有必要这样做,因为您可以随时回来通过SDK Managerandroid添加这些版本。

确保只检查了这些条目后,单击 Install 按钮开始安装。图 12-6 显示了产生的安装档案对话框。

images

图 12-6。Installing Archives 对话框显示下载和安装每个所选软件包档案的进度。

安装归档文件可能会显示“adb kill-server”失败—如有必要,请手动运行”消息。这个消息指的是一个名为adb的平台工具,代表 Android Debug Bridge (ADB)。

ADB 管理仿真器实例或 Android 驱动设备的状态。它包括一个在开发机器上作为后台进程运行的服务器。安装程序必须在安装平台工具之前终止该进程。当这个过程没有运行时,您会看到前面提到的消息。

您可能会遇到 ADB 重启对话框,它告诉您依赖于 Android Debug Bridge (ADB)的一个包已经更新,并询问您是否要立即重启 ADB。此时,您单击哪个按钮并不重要-当 ADB 服务器进程在您开始安装软件包之前已经运行,并且您希望在安装之后继续该进程时,您可能会单击“是”。

单击“安装归档文件”对话框中的“关闭”以完成安装。

现在,您应该注意到 Android SDK 和 AVD 管理器的已安装软件包窗格显示了 Android SDK 平台工具,修订版 6 和 SDK 平台 Android 2.3.3,API 10,修订版 2 以及 Android SDK 工具,修订版 12。您还应该观察以下新子目录:

  • platform-tools(在android-sdk-windows中)
  • android-10(在android-sdk-windows/platforms中)

platform-tools 包含的开发工具可能会随着每个平台版本的发布而更新。其工具包括aapt (Android 资产打包工具——查看、创建、更新兼容 Zip 的档案(.zip.jar.apk);并将资源编译成二进制资产)、前面提到的adb工具、dx (Dalvik 可执行文件——从 Java“.class”文件生成 Dalvik DEX 代码)。android-10存储 Android 2.3.3 数据和面向用户界面的文件。

images 提示您可能想要将platform-tools添加到您的PATH环境变量中,以便您可以从文件系统中的任何地方访问这些工具。

创建和启动 AVD

安装 Android SDK 和 Android 平台后,您就可以开始开发 Android 应用了。如果你没有真正的 Android 设备来安装和运行这些应用,你可以使用emulator工具来模拟一个设备。该工具与 AVD 协同工作,AVD 是描述仿真设备的各种特征(例如,屏幕大小)的描述符。

images 提示即使你有一个真正的 Android 设备,你也应该用模拟器测试你的应用,看看它们在不同屏幕尺寸下的表现。

本节首先向您展示如何创建 AVD 来描述仿真设备。然后向您展示如何启动 AVD,并带您浏览其用户界面。

创建一个 AVD

通过SDK Managerandroid启动 Android SDK 和 AVD 管理器对话框。您可能更喜欢使用android,它可以防止刷新源代码和选择要安装的软件包对话框出现。如图图 12-5 和图 12-6 所示,虚拟设备窗格中没有列出任何 avd。

单击新建按钮。图 12-7 显示了创建新的 Android 虚拟设备(AVD)对话框。

images

图 12-7。AVD 由名称、目标平台和其他特征组成。

图 12-7 揭示了一个 AVD 有一个名字,目标是一个特定的 Android 平台,等等。输入 test_AVD 作为名称,选择Android 2.3.3 – API Level 10作为目标平台,在 SD 卡的尺寸字段输入100

选择Android 2.3.3 – API Level 10会导致为 AVD 的皮肤选择Default (WVGA800)。此外,它还提供以下三种硬件属性:

  • 抽象的 LCD 密度,设置为每英寸 240 点
  • 最大虚拟机应用堆大小,设置为 24MB
  • 设备 ram 大小,设置为 256MB

images 提示要在 1024x768 的平台屏幕分辨率下查看整个设备屏幕,您需要将皮肤从默认的(WVGA800)更改为更低的,例如 HVGA。切换到 HVGA 也改变抽象的 LCD 密度为 160。

保留屏幕默认设置和/或进行更改后,点击创建 AVD。然后在出现的 Android 虚拟设备管理器对话框中单击 OK,该对话框总结了 AVD。“虚拟设备”窗格现在包含一个 test_AVD 条目。

启动 AVD

在安装和运行应用之前,您必须启动 AVD,这可能需要几分钟时间。通过突出显示 test_AVD 条目(在虚拟设备窗格上)并单击开始按钮来完成此任务。

出现启动选项对话框,确定 AVD 的皮肤和屏幕密度。它还提供了未选中的复选框,用于缩放模拟器显示的分辨率以匹配物理设备的屏幕大小,以及擦除用户数据。

images 注意当你更新你的应用时,你将定期打包并安装在仿真设备上,这将在用户数据磁盘分区中跨 AVD 重启保留应用及其状态数据。为了确保应用在更新时正常运行,您可能需要删除 AVD 的用户数据分区,这可以通过选中擦除用户数据来完成。

单击“启动选项”对话框中的“启动”按钮,启动带有 AVD 的仿真器。出现一个启动 Android 模拟器对话框,随后是命令窗口(在 Windows XP 上)和 AVD 的主窗口。

主窗口分为左窗格和右窗格,左窗格在黑色背景上显示 Android 徽标,然后是主屏幕,右窗格显示手机控制和键盘。图 12-8 显示了test_AVD装置的这些窗格。

images

图 12-8。AVD 窗口在其左侧呈现主屏幕,在其右侧呈现电话控制和键盘。

如果你以前用过 Android 设备,你可能对主屏幕、手机控制和键盘很熟悉。如果没有,请记住以下几点:

  • 主屏幕(见图 12-8 的左窗格)是一个特殊的应用,作为使用 Android 设备的起点。它显示背景壁纸。您可以通过单击菜单按钮(在电话控制中)并在弹出菜单中选择壁纸来更改壁纸。
  • 主屏幕(以及每个应用屏幕)上方会出现一个状态栏。状态栏显示当前时间、电池剩余电量等信息;并且还提供对通知的访问。
  • 主屏幕显示壁纸背景。单击电话控件中的菜单按钮,然后在弹出菜单中单击壁纸,以更改壁纸。
  • 主屏幕能够显示小工具,这是可以嵌入主屏幕和其他应用屏幕的微型应用视图,并接收定期更新。例如,谷歌搜索小工具出现在图 12-8 的主屏幕顶部附近。
  • 应用启动器出现在主屏幕底部附近。单击其矩形网格图标切换到应用图标的应用启动器屏幕,并单击这些图标中的任何一个来启动相应的应用。该启动器还显示了用于启动常用电话和浏览器应用的图标。
  • 主屏幕由多个窗格组成。单击应用启动器任一侧的点,将当前窗格替换为左侧或右侧的下一个窗格。左侧或右侧仍待访问的窗格数量由应用启动器左侧或右侧的圆点数量表示。
  • 房子图标电话控制按钮带你从任何地方到主屏幕。
  • 菜单手机控制按钮提供了一个上下文菜单,为当前运行的应用的当前屏幕提供了特定于应用的选项。
  • 弯曲的箭头图标(后退)电话控制按钮将带您返回到活动堆栈中的上一个活动,该堆栈是以前访问过的屏幕的堆栈。

当 AVD 运行时,您可以通过使用鼠标“触摸”触摸屏和键盘“按下”设备键来与它进行交互。下表列出了从 AVD 键到开发计算机键盘键的一些映射:

  • 回家地图
  • 菜单(左软键)映射到 F2 或向上翻页
  • 星号(右侧软键)映射到 Shift-F2 或 Page Down
  • 反向映射到 Esc
  • 切换到以前的布局方向(例如,纵向或横向)映射到 KEYPAD_7,Ctrl-F11
  • 切换到下一个布局方向映射到小键盘 _9,Ctrl-F12

images 提示在使用键盘按键之前,您必须首先禁用开发计算机上的 NumLock。

图 12-8 标题栏显示 5554:test_AVD。5554 值标识了一个控制台端口,您可以使用它来动态查询和控制 AVD 的环境。

images 注意 Android 最多支持 16 个并发执行的 avd。每个 AVD 都分配有一个从 5554 开始的偶数控制台端口号。

创建、安装和运行应用

现在,您已经安装了 Android SDK,安装了 Android 平台,并创建和启动了 AVD,您已经准备好创建一个应用,并在 AVD 上安装和运行这个应用。本节向您介绍一款名为Java7MeetsAndroid的应用。在介绍和讨论了应用的源代码和相关文件之后,它将向您展示如何创建这个应用,并在之前启动的 AVD 上安装和运行它。

【Java7MeetsAndroid 简介

Java7MeetsAndroid是一个单活动的应用,呈现一个图像和一个按钮。这张图片展示了一个发光的 7 上的 Java 吉祥物杜克。点击标记为 Wave 的按钮时,会播放杜克挥手的动画。

images 查看“杜克,Java 吉祥物”([kenai.com/projects/duke/pages/Home](http://kenai.com/projects/duke/pages/Home))了解更多关于这个酷角色的信息。

清单 12-3 展示了Java7MeetsAndroid类。

清单 12-3。制造公爵波的活动

package ca.tutortutor.j7ma;

import android.app.Activity;

import android.graphics.drawable.AnimationDrawable;

import android.os.Bundle;

import android.view.View;

import android.widget.Button;
import android.widget.ImageView;

public class Java7MeetsAndroid extends Activity
{
    AnimationDrawable dukeAnimation;
    @Override
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.main);
        ImageView dukeImage = (ImageView) findViewById(R.id.duke);
        dukeImage.setBackgroundResource(R.drawable.duke_wave);
        dukeAnimation = (AnimationDrawable) dukeImage.getBackground();
        final Button btnWave = (Button) findViewById(R.id.wave);

        View.OnClickListener ocl;
        ocl = new View.OnClickListener()
        {
           @Override
           public void onClick(View v)
           {
              dukeAnimation.stop();
              dukeAnimation.start();
           }
        };
        btnWave.setOnClickListener(ocl);
    }
}

清单 12-3 以一个 package 语句开始,该语句命名存储其Java7MeetsAndroid类的包(ca.tutortutor.j7ma),后面是一系列导入各种 Android API 类型的 import 语句。这个清单接下来描述了扩展了ActivityJava7MeetsAndroid类。

Java7MeetsAndroid首先声明一个类型为android.graphics.drawable.AnimationDrawabledukeAnimation实例字段。类型AnimationDrawable的对象描述逐帧动画,其中当前的可绘制对象被替换为动画序列中的下一个可绘制对象。

images 注意 AnimationDrawable间接扩展了抽象android.graphics.drawable.Drawable类,它是对一个 drawable 的通用抽象,可以被绘制的东西(例如一个图像)。

应用的所有工作都发生在Java7MeetsAndroid的覆盖onCreate(Bundle)方法中:不需要其他方法,这有助于保持这个应用的简单。

首先调用它的同名超类方法,这是一个所有覆盖活动方法都必须遵循的规则。

然后这个方法执行setContentView(R.layout.main)来建立应用的用户界面。R.layout.main是应用资源的标识符(ID ),它位于一个单独的文件中。您对该 ID 的解释如下:

  • R是构建应用时生成的类的名称(由aapt工具生成)。这个类被命名为R,因为它的内容标识了各种应用资源(例如,布局、图像、字符串和颜色)。
  • layout是嵌套在R中的类的名称。其 id 存储在该类中的所有应用资源描述特定的布局资源。每种应用资源都与一个以相似方式命名的嵌套类相关联。例如,string标识字符串资源。
  • main是在layout中声明的int常量的名称。该资源 ID 标识主布局资源。具体来说,main指的是一个存储主活动布局信息的main.xml文件。mainJava7MeetsAndroid唯一的布局资源。

R.layout.main被传递给Activityvoid setContentView(int layoutResID)方法,告诉 Android 使用main.xml中存储的布局信息创建一个用户界面屏幕。在幕后,Android 创建了在main.xml中描述的用户界面组件,并根据main.xml的布局数据将它们放置在屏幕上。

该屏幕基于视图(用户界面组件的抽象)和视图组(对相关用户界面组件进行分组的视图)。视图是子类化android.view.View类的实例,类似于 AWT/Swing 组件。视图组是抽象android.view.ViewGroup类的子类实例,类似于 AWT/Swing 容器。Android 将特定的视图(如按钮或微调器)称为小部件

images 注意不要把这里的 widget 和 Android 主屏幕上显示的 widget 混淆了。虽然使用了相同的术语,但是用户界面部件和主屏幕部件是不同的。

继续,onCreate(Bundle)执行ImageView dukeImage = (ImageView) findViewById(R.id.duke);。该语句首先调用ViewView findViewById(int id)方法,找到main.xml中声明的标识为dukeandroid.widget.ImageView元素,实例化ImageView并初始化为其声明性信息。然后,该语句将该对象的引用保存在局部变量dukeImage中。

随后的dukeImage.setBackgroundResource(R.drawable.duke_wave);语句调用ImageView的 inherited (from View ) void setBackgroundResourceMethod(int resID)方法,将视图的背景设置为由resID标识的资源。R.drawable.duke_wave参数标识一个名为duke_wave.xml(稍后介绍)的 XML 文件,该文件存储动画信息,并存储在resdrawable子目录中。setBackgroundResource()调用将dukeImage视图链接到由duke_wave.xml描述的图像序列,并将在该视图上绘制;该方法调用的结果是绘制初始图像。

让一个应用通过调用AnimationDrawable方法来动画显示一系列的可绘制图形。在应用可以这样做之前,它必须获得ImageViewAnimationDrawable。下面的dukeAnimation = (AnimationDrawable) dukeImage.getBackground();赋值语句通过调用ImageView的 inherited (from View ) Drawable getBackground()方法返回这个ImageViewAnimationDrawable来完成这个任务,这个AnimationDrawable随后被赋值给dukeAnimation字段。AnimationDrawable实例用于开始和停止动画(稍后讨论)。

onCreate(Bundle)现在将注意力转向创建波形按钮。它调用findByViewId(int)main.xml获取按钮信息,然后实例化android.widget.Button类。

然后使用View类的嵌套onClickListener接口创建一个监听器对象,每当用户点击按钮时,就会调用该对象的void onClick(View v)方法。监听器通过调用Viewvoid setOnClickListener(AdapterView.OnClickListener listener)方法注册到它的Button对象。

Wave 的 click listener 调用dukeAnimation.stop();然后调用dukeAnimation.start();来停止然后开始动画。在start()之前调用stop()方法,以确保随后单击波形按钮会导致新的动画开始。

与清单 12-3 的Java7MeetsAndroid.java源文件一起,Java7MeetsAndroid依赖于三个 XML 资源文件和几个 PNG 图像。清单 12-4 展示了main.xml,它描述了屏幕布局。

清单 12-4。存储布局信息的main.xml文件,包括一对小工具

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              android:orientation="vertical"
              android:layout_width="fill_parent"
              android:layout_height="fill_parent"
              android:gravity="center"
              android:background="#ffffff">
   <ImageView android:id="@+id/duke"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:layout_marginBottom="10dip"/>
   <Button android:id="@+id/wave"
              android:layout_width="wrap_content"
              android:layout_height="wrap_content"
              android:text="@string/wave"/>
</LinearLayout>

在 XML 声明之后,清单 12-4 声明了一个LinearLayout元素,它指定了一个布局(一个视图组,以某种方式在 Android 设备的屏幕上排列包含的视图),用于在屏幕上水平或垂直排列包含的小部件(包括嵌套布局)。

标签指定了几个属性来控制这个线性布局。这些属性包括以下内容:

  • orientation将线性布局标识为水平或垂直——包含的小部件水平或垂直布局。默认方向是水平的。"horizontal""vertical"是唯一可以分配给该属性的合法值。
  • layout_width标识布局的宽度。合法值包括"fill_parent"(与父级一样宽)和"wrap_content"(足够宽以包含内容)。fill_parent在 Android 2.2 中更名为match_parent,但仍被支持和广泛使用。
  • layout_height标识布局的高度。合法值包括"fill_parent"(与父项一样高)和"wrap_content"(足够高以包含内容)。
  • gravity标识布局相对于屏幕的位置。例如,"center"指定布局应该在屏幕上水平和垂直居中。
  • background标识背景图像、渐变或纯色。为了简单起见,我硬编码了一个十六进制颜色标识符来表示纯白色背景(#ffffff)。

LinearLayout元素封装了ImageViewButton元素。这些元素中的每一个都指定了一个id属性来标识元素,以便可以从代码中引用它。分配给该属性的资源标识符(以@开头的特殊语法)以@+id前缀开始。对于示例,@+id/dukeImageView元素标识为duke;通过指定R.id.duke从代码中引用该元素。

这些元素还指定了layout_widthlayout_height属性,用于确定它们的内容是如何布局的。每个属性都被赋予了wrap_content,这样元素就会以其自然大小出现。

ImageView指定一个layout_marginBottom属性来标识其自身和垂直跟随的按钮之间的空格分隔符。该空间被指定为 10 个倾角,或与密度无关的像素(应用可以使用虚拟像素以与屏幕密度无关的方式表达布局尺寸/位置)。

images 注意一个与密度无关的像素相当于 160 dpi 屏幕上的一个物理像素,这是 Android 假定的基线密度。在运行时,Android 透明地处理所需 dip 单位的任何缩放,基于使用中的屏幕的实际密度。倾角单位通过等式像素=倾角*(密度/ 160)转换为屏幕像素。例如,在 240 dpi 的屏幕上,1 个 dip 等于 1.5 个物理像素。Google 建议使用 dip 单位来定义应用的用户界面,以确保 UI 在不同屏幕上的正确显示。

元素的属性被赋值为@string/wave,它引用了一个名为wave的字符串资源。这个字符串资源存储在一个名为strings.xml的 XML 文件中,该文件存储在resvalues子目录中。

清单 12-5 描述了strings.xml的内容。

清单 12-5。存储应用字符串的strings.xml文件

<?xml version="1.0" encoding="utf-8"?>
<resources>
   <string name="app_name">Java7MeetsAndroid</string>
   <string name="wave">Wave</string>
</resources>

除了wave,清单 12-5 揭示了一个标识为app_name的字符串资源。该资源 ID 标识应用的名称,并从应用的清单中引用,通常从application元素开始标记的label属性中引用(参见清单 12-2 )。

清单 12-6 呈现duke_wave.xml

清单 12-6。保存 app 动画可绘制项目列表的duke_wave.xml文件

<animation-list xmlns:android="http://schemas.android.com/apk/res/android"
                android:oneshot="true">
   <item android:drawable="@drawable/duke0" android:duration="100" />
   <item android:drawable="@drawable/duke1" android:duration="100" />
   <item android:drawable="@drawable/duke2" android:duration="100" />
   <item android:drawable="@drawable/duke3" android:duration="100" />
   <item android:drawable="@drawable/duke4" android:duration="100" />
   <item android:drawable="@drawable/duke5" android:duration="100" />
   <item android:drawable="@drawable/duke6" android:duration="100" />
   <item android:drawable="@drawable/duke7" android:duration="100" />

   <item android:drawable="@drawable/duke8" android:duration="100" />
   <item android:drawable="@drawable/duke9" android:duration="100" />
   <item android:drawable="@drawable/duke0" android:duration="100" />
</animation-list>

清单 12-6 展示了通过dukeImage.setBackgroundResource(R.drawable.duke_wave);语句连接到dukeImage的可绘制图形的动画列表。

images 注意animation-list元素的oneshot属性决定动画是循环播放(当该属性被赋予"false"时)还是只出现一次(当该属性被赋予"true"时)。当"true"被赋值给oneshot时,你必须在AnimationDrawable()stop()方法之前调用它的start()方法来生成另一个单镜头动画序列。

嵌套在animation-list元素中的是一系列item元素。每个item元素通过其drawable属性在动画序列中标识一个可绘制元素。@drawable/duke*x*资源引用(其中 x 范围从09)标识resdrawable目录中名称以duke开头的图像文件。duration属性标识在显示下一个item元素的 drawable 之前必须经过的毫秒数。

清单 12-7 展示了Java7MeetsAndroidAndroidManifest.xml文件。

清单 12-7。描述Java7MeetAndroid app

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="ca.tutortutor.j7ma"
          android:versionCode="1"
          android:versionName="1.0">
   <application android:label="@string/app_name" android:icon="@drawable/icon">
      <activity android:name="java7meetsandroid"
                android:label="@string/app_name">
         <intent-filter>
            <action android:name="android.intent.action.main" />
            <category android:name="android.intent.category.launcher" />
         </intent-filter>
      </activity>
   </application>
</manifest>

创建 Java7MeetsAndroid

创建Java7MeetsAndroid必须遵循几个步骤。第一步是使用android工具创建一个项目。以这种方式使用时,android要求您遵守以下语法(为了可读性,该语法分布在多行中):

android create project --target *target_ID*
                       --name *your_project_name*

                       --path /*path*/*to*/*your*/*project*/*project_name*
                       --activity *your_activity_name*
                       --package *your_package_namespace*

除了指定项目名称的--name(或–n)(如果提供了此名称,则在您构建应用时,此名称将用于生成的.apk文件名)之外,以下所有选项都是必需的:

  • --target(或-t)选项指定了应用的构建目标。 target_ID 值是标识 Android 平台的整数值。您可以通过调用android list targets来获得这个值。如果您只安装了 Android 2.3.3 平台,这个命令应该输出一个标识为 integer ID 1 的 Android 2.3.3 平台目标。
  • --path(或-p)选项指定项目目录的位置。如果目录不存在,则创建该目录。
  • --activity(或-a)选项指定默认活动类的名称。生成的 classfile 在/*path*/*to*/*your*/*project*/*project_name*/*src*/*your_package_namespace*/中创建,如果没有指定--name(或-n)的话,它将被用作.apk文件名。
  • --package(或-k)选项指定项目的包名称空间,它必须遵循 Java 语言中指定的包规则。

假设一个 Windows XP 平台,并假设一个C:\prj\dev层次结构,其中Java7MeetsAndroid项目将存储在C:\prj\dev\Java7MeetsAndroid中,从文件系统中的任何地方调用以下命令来创建Java7MeetsAndroid:

android create project -t 1 -p C:\prj\dev\Java7MeetsAndroid -a Java7MeetsAndroid -k![images](https://gitee.com/OpenDocCN/vkdoc-java-zh/raw/master/docs/begin-java7/img/U002.jpg)
ca.tutortutor.j7ma

该命令创建各种目录,并向其中一些目录添加文件。它在C:\prj\dev\Java7MeetsAndroid中专门创建了以下文件和目录结构:

  • AndroidManifest.xml是正在构建的应用的清单文件。该文件与之前通过--activity-a选项指定的Activity子类同步。
  • bin是 Apache Ant 构建脚本的输出目录。
  • build.properties是构建系统的可定制属性文件。您可以编辑该文件以覆盖 Apache Ant 使用的默认构建设置,并提供一个指向您的密钥库和密钥别名的指针,以便构建工具可以在以发布模式构建您的应用时对其进行签名(在 Android Recipes 中讨论)。
  • build.xml是该项目的 Apache Ant 构建脚本。
  • default.properties是构建系统的默认属性文件。不要修改这个文件。
  • libs包含私有库(需要时)。
  • local.properties包含 Android SDK 主目录的位置。
  • proguard.cfg包含了 ProGuard 的配置数据,这是一个 SDK 工具,允许开发人员将他们的代码混淆(使代码很难逆向工程)作为发布版本的一个集成部分。
  • res包含项目的应用资源。
  • src包含项目的源代码。

res包含以下目录:

  • drawable-hdpi包含用于高密度屏幕的可绘制资源(如图标)。
  • drawable-ldpi包含低密度屏幕的可提取资源。
  • drawable-mdpi包含中密度屏幕的可抽取资源。
  • layout包含布局文件。
  • values包含值文件。

另外,src包含了ca\tutortutor\j7ma目录结构,最后的j7ma子目录包含了一个骨架Java7MeetsAndroid.java源文件。

在创建此应用之前,您需要执行以下任务:

  • 用清单 12-3 中的替换骨架Java7MeetsAndroid.java源文件。
  • layout子目录的骨架main.xml文件替换为清单 12-4 。
  • values子目录的骨架strings.xml文件替换为清单 12-5 。
  • res下创建一个drawable目录。将本书代码文件中的duke0.pngduke9.png图片连同清单 12-6 的duke_wave.xml文件一起复制到drawable

生成的AndroidManifest.xml文件应该没问题,尽管如果需要,您可以用清单 12-7 中的替换它。

假设C:\prj\dev\Java7MeetsAndroid是最新的,在 Apache 的ant工具的帮助下构建这个应用,默认处理这个目录的build.xml文件。在命令行中,指定ant,后跟debugrelease,以指示构建模式:

  • 调试模式:搭建 app 进行测试调试。构建工具用调试密钥对结果 APK 进行签名,并用zipalign优化 APK。指定ant debug
  • 发布方式:构建 app 发布给用户。您必须用您的私钥签署结果 APK,然后用zipalign优化 APK。(我在 Android 食谱中讨论了这些任务。)指定ant release

通过从C:\prj\dev\Java7MeetsAndroid目录调用ant debug在调试模式下构建Java7MeetsAndroid。该命令创建一个包含ant生成的R.java文件的gen子目录(在ca\tutortutor\j7ma目录层次中),并将创建的Java7MeetsAndroid-debug.apk文件存储在bin子目录中。

安装并运行 Java7MeetsAndroid

如果你成功创建了Java7MeetsAndroid-debug.apk,你可以在之前启动的 AVD 上安装这个 APK。您可以使用adb工具完成这项任务,如下所示:

adb install C:\prj\dev\Java7MeetsAndroid\bin\Java7MeetsAndroid-debug.apk

过一会儿,您应该会看到几条类似如下所示的消息:

325 KB/s (223895 bytes in 0.671s)
        pkg: /data/local/tmp/Java7MeetsAndroid-debug.apk
Success

如果您遇到“设备脱机”错误消息,您可能需要重复几次上述命令行。

选择主屏幕底部的应用启动器(网格)图标。图 12-9 显示了高亮显示的Java7MeetsAndroid条目。

images

图 12-9。高亮显示的Java7MeetsAndroid应用入口显示标准图标和标签,当图标和标签高亮显示时,自动水平滚动。

images 注意res目录下的drawable-hdpidrawable-mdpidrawable-ldpi子目录下都包含一个icon.png文件,该文件呈现了图 12-9 所示的不同大小的默认图标。如果需要,您可以用自己的图标替换所有三个版本的图标。

单击突出显示的图标,您应该会看到如图图 12-10 所示的屏幕——我已经单击了波形按钮,因此该屏幕正在显示动画的一帧。

images

图 12-10。每次你点击挥手,杜克都会向你挥手。

当你厌倦了玩这个应用时,单击手机控制中的后退(弯曲的箭头)按钮或按 Esc 键返回到上一个屏幕,这应该是带有应用图标的应用启动器。

您可以卸载此应用,方法是单击菜单按钮(在应用启动器屏幕上),从弹出菜单中选择管理应用,在应用列表中高亮显示Java7MeetsAndroid,单击此条目,然后单击卸载按钮。

images 提示在开发过程中,你会发现使用adb工具卸载一个 app 更简单快捷。例如,指定adb uninstall ca.tutortutor.j7ma卸载Java7MeetsAndroid。您必须指定应用的软件包名称才能卸载它。

练习

以下练习旨在测试您对 Android 应用开发的理解:

  1. 使用清单 12-1 创建SimpleApp作为这个应用的SimpleActivity.java源文件的源代码。您应该在bin子目录中找到一个SimpleApp-debug.apk文件。(提示:你需要使用android工具的-n命令行选项。)在运行的 test_AVD 仿真设备上安装此 APK。
  2. 当你在应用启动器屏幕上查看这个应用的图标和标签时,你会注意到标签上写的是SimpleActivity而不是SimpleApp。为什么呢?
  3. 如何从 test_AVD 中卸载SimpleApp
  4. 通过包含一个SimpleActivity2.java源文件来扩展SimpleApp,该源文件的onCreate(Bundle)方法类似于SimpleActivity.javaonCreate(Bundle)方法,但由super.onCreate(savedInstanceState);后跟Toast.makeText(this, getIntent().toString(), Toast.LENGTH_LONG).show();组成。(android.widget.Toast类用于代替使用System.out.println()简单显示简短的调试消息,只有在调用adb logcat后才能查看其输出。因为如此多的消息被输出到这个日志中,所以可能很难找到System.out.println()的内容,这就是为什么您可能会发现Toast更有用。)重构SimpleActivityonCreate(Bundle)方法,通过隐式意图启动SimpleActivity2,如本章前面所演示的。
  5. 继续练习 4,创建SimpleApp(确保重构AndroidManifest.xml以解释SimpleActivity2)。安装好重构后的SimpleApp后,点击它的 app launcher StartActivity图标。会发生什么?

总结

《Android 开发人员指南》将 Android 定义为移动设备的软件栈(交付全功能解决方案所需的一组软件子系统)。这个堆栈包括操作系统(Linux 内核的修改版本)、中间件(将低级操作系统连接到高级应用的软件)和关键应用(用 Java 编写),如 web 浏览器(称为浏览器)和联系人管理器(称为联系人)。

Android 提供了一个分层的架构,包括应用框架(活动管理器、内容提供者、位置管理器、通知管理器、包管理器、资源管理器、电话管理器、视图系统和窗口管理器)、库(FreeType、libc、LibWebCore、Media Framework、OpenGL | ES、SGL、SQLite、SSL 和 Surface Manager)、Android 运行时(核心库和 Dalvik 虚拟机)以及 Linux 内核。

Android 应用的架构不同于在桌面上运行的应用的架构。应用架构基于组件(活动、广播接收器、内容提供者和服务),这些组件通过意图相互通信,由清单描述,并且可能使用应用资源。总的来说,这些项目存储在一个应用包中,也称为 APK。

在创建应用之前,您需要安装 Android SDK 和 Android 平台。然后,您需要创建一个 AVD 并启动 AVD,然后才能安装和运行您的应用。

描述了一个呈现图像和按钮的单活动应用。这张图片展示了一个发光的 7 上的 Java 吉祥物杜克。点击标记为 Wave 的按钮时,会播放杜克挥手的动画。除了其Java7MeetsAndroid.java源文件之外,该应用还包括main.xmlstrings.xmlduke_wave.xml以及duke0.pngduke9.png应用资源文件。它也有自己的AndroidManifest.xml清单。

posted @ 2024-08-06 16:33  绝不原创的飞龙  阅读(0)  评论(0编辑  收藏  举报