Java核心技术卷阅读随笔--第6章【接 口、lambda 表达式与内部类】
接 口、lambda 表达式与内部类
到目前为止,读者已经学习了 Java 面向对象程序设计的全部基本知识。本章将开始介绍几种常用的高级技术。这些内容可能不太容易理解,但一定要掌握它们,以便完善自己的 Java 工具箱。
首先,介绍一下接口( interface) 技术, 这种技术主要用来描述类具有什么功能,而并不 给出每个功能的具体实现。一个类可以实现( implement) —个或多个接口,并在需要接口的 地方, 随时使用实现了相应接口的对象。 了解接口以后,再继续介绍lambda表达式,这是 一种表示可以在将来某个时间点执行的代码块的简洁方法。使用 lambda 表达式,可以用一 种精巧而简洁的方式表示使用回调或变量行为的代码。
接下来,讨论内部类( inner class) 机制。理论上讲,内部类有些复杂, 内部类定义在另 外一个类的内部, 其中的方法可以访问包含它们的外部类的域。内部类技术主要用于设计具有相互协作关系的类集合。 在本章的最后还将介绍代理(proxy), 这是一种实现任意接口的对象。代理是一种非常专业的构造工具,它可以用来构建系统级的工具。如果是第一次学习这本书,可以先跳过这个部分。
6.1 接口
在下面的小节中,你会了解 Java 接口是什么以及如何使用接口,另外还会了解 Java SE 8 中接口的功能有怎样的提升。
6.1.1 接口概念
在 Java 程序设计语言中, 接口不是类,而是对类的一组需求描述,这些类要遵从接口描 述的统一格式进行定义。
我们经常听到服务提供商这样说:“ 如果类遵从某个特定接口,那么就履行这项服务 下面给出一个具体的示例。Arrays 类中的 sort 方法承诺可以对对象数组进行排序,但要求满足下列前提:对象所属的类必须实现了 Comparable 接口。
下面是 Comparable 接口的代码:
public interface Comparable { int compareTo(Object other); }
这就是说,任何实现 Comparable 接口的类都需要包含 compareTo 方法,并且这个方法的参 数必须是一个 Object 对象,返回一个整型数值。
注释: 在 JavaSE 5.0 中,Comparable 接口已经改进为泛型类型。
public interface Comparable{ int compareTo(T other) ; // parameter has type T }
例如,在实现 Comparable 接口的类中, 必须提供下列方法
int compareTo(Employee other)
还可以使用不带类型参数的“ 原始” Comparable 类型。 这样一来, compareTo 方法 就有一个 Object 类型的参数, 必须手动将 compareTo 方法的这个参数强制转换为所希望 的类型。 稍后我们就会做这个工作,所以不用担心同时出现两个新概念。
接口中的所有方法自动地属于 public。 因此,在接口中声明方法时,不必提供关键字 public。
当然,接口中还有一个没有明确说明的附加要求:在调用 X.compareTo(y) 的时候,这个 compareTo 方法必须确实比较两个对象的内容, 并返回比较的结果。 当 x 小于 y 时, 返回一 个负数;当 x 等于 y 时, 返回 0; 否则返回一个正数。
上面这个接口只有一个方法,而有些接口可能包含多个方法。稍后可以看到,在接口 中还可以定义常量。 然而, 更为重要的是要知道接口不能提供哪些功能。接口绝不能含有实例域, 在 JavaSE 8之前, 也不能在接口中实现方法。(在 6.1.4 节和 6.1.5 节中可以看 到,现在已经可以在接口中提供简单方法了。当然, 这些方法不能引用实例域接口---接口没有实例。)
提供实例域和方法实现的任务应该由实现接口的那个类来完成。因此, 可以将接口看成 是没有实例域的抽象类。但是这两个概念还是有一定区别的, 稍后将给出详细的解释。
现在, 假设希望使用 Arrays 类的 sort 方法对 Employee 对象数组进行排序, Employee 类 就必须实现 Comparable 接口。
为了让类实现一个接口, 通常需要下面两个步骤:
1 ) 将类声明为实现给定的接口。
2 ) 对接口中的所有方法进行定义。
要将类声明为实现某个接口, 需要使用关键字 implements:
class Employee implements Comparable
当然, 这里的 Employee 类需要提供 compareTo 方法。 假设希望根据雇员的薪水进行比较。以下是 compareTo 方法的实现:
public int compareTo(Object otherObject) { Employee other = (Employee) otherObject; return Double.compare(salary, other.salary); }
在这里,我们使用了静态 Double.compare 方法,如果第一个参数小于第二个参数, 它会返回 一个负值;如果二者相等则返回 0; 否则返回一个正值。
警告:在接口声明中,没有将 compareTo 方法声明为 public, 这是因为在接口中的所有 方法都自动地是 public。不过,在实现接口时, 必须把方法声明为 public; 否则, 编译器 将认为这个方法的访问属性是包可见性, 即类的默认访问属性,之后编译器就会给出试图提供更严格的访问权限的警告信息。
我们可以做得更好一些。可以为泛型 Comparable 接口提供一个类型参数。
class Employee implements Comparable{ public int compareTo(Employee other) {
return Double.compare(salary,other.salary);
}
...
}
请注意, 对 Object 参数进行类型转换总是让人感觉不太顺眼, 但现在已经不见了。
提示: Comparable 接口中的 compareTo 方法将返回一个整型数值。如果两个对象不相等, 则返回一个正值或者一个负值。在对两个整数域进行比较时,这点非常有用。例如,假 设每个雇员都有一个唯一整数 id,并希望根据 ID 对雇员进行重新排序, 那么就可以返回 id-other.id。如果第一个 ID 小于另一个 ID, 则返回一个负值;如果两个 ID 相等, 则返回 0 ; 否则, 返回一个正值。但有一点需要注意: 整数的范围不能过大, 以避免造成减法运算的溢出。如果能够确信 ID 为非负整数, 或者它们的绝对值不会超过(Integer_MAX_ VALUE-1)/2, 就不会出现问题。否则, 调用静态 Integer.compare 方法。
当然,这里的相减技巧不适用于浮点值。 因为在 salary 和 other.salary 很接近但又不 相等的时候, 它们的差经过四舍五入后有可能变成 0。x < y 时,Double.compare(x, y) 调 用会返回 -1 ; 如果 x > y 则返回 1。
现在, 我们已经看到,要让一个类使用排序服务必须让它实现 compareTo 方法。这是理 所当然的, 因为要向 sort 方法提供对象的比较方式。但是为什么不能在 Employee 类直接提 供一个 compareTo 方法,而必须实现 Comparable 接口呢?
主要原因在于 Java 程序设计语言是一种强类型 ( strongly typed) 语言。在调用方法的时 候, 编译器将会检查这个方法是否存在。在 sort 方法中可能存在下面这样的语句:
if (a[i]. compareTo(a[j]) > 0) { // rearrange a[i] and a[j] ... }
为此, 编译器必须确认 a[i] —定有 compareTo 方法。如果 a 是一个 Comparable 对象的数组, 就可以确保拥有 compareTo 方法,因为每个实现 Comparable 接口的类都必须提供这个方法的定义。
注释: 有人认为, 将 Arrays 类中的 sort 方法定义为接收一个 Comparable[ ] 数组就可以在 使用元素类型没有实现 Comparable 接口的数组作为参数调用 sort 方法时, 由编译器给出 错误报告。但事实并非如此。在这种情况下, sort 方法可以接收一个 Object[ ] 数组, 并 对其进行笨拙的类型转换:
// Approach used in the standard library not recommended if (((Comparable) a[i]) . compareTo(a[j]) > 0) { // rearrange a[i] and a[j] ... }
如果 a[i] 不属于实现了 Comparable 接口的类, 那么虚拟机就会抛出一个异常。
API java.lang.Comparable 1.0
? int compareTo(T other)
用这个对象与 other 进行比较。如果这个对象小于 other 则返回负值; 如果相等则返回 0;否则返回正值。
API java.util.Arrays 1.2
? static void sort( Object[] a )
使用 mergesort 算法对数组 a 中的元素进行排序。要求数组中的元素必须属于实现了 Comparable 接口的类, 并且元素之间必须是可比较的。
API java.lang.lnteger 1.0
? static int compare(int x , int y) 7
如果 x < y 返回一个负整数;如果 x 和 y 相等,则返回 0; 否则返回一个正整数。
API java.lang.Double 1.0
?static int compare(double x , double y) 1.4
如果 x < y 返回一个负数;如果 x 和 y 相等则返回 0; 否则返回一个正数
注释:语 言 标 准 规 定:对 于 任 意 的 x 和 y, 实 现 必 须 能 够 保 证 sgn(x.compareTo(y)) = -sgn (y.compareTo(x)。) (也 就 是 说, 如 果 y.compareTo(x) 抛 出 一 个 异 常, 那 么 x.compareTo (y) 也 应 该 抛 出 一 个 异 常。)这 里 的“ sgn” 是 一 个 数 值 的 符 号:如 果 n 是 负 值, sgn(n) 等 于 -1 ; 如 果 n 是 0, sgn(n) 等 于 0 ; 如 果 n 是 正 值, sgn(n)等 于 1 。简 单 地 讲, 如 果 调 换 compareTo 的 参 数, 结 果 的 符 号 也 应 该 调 换 (而 不 是 实 际 值)。
与 equals 方 法 一 样, 在 继 承 过 程 中 有 可 能 会 出 现 问 题。
这 是 因 为 Manager 扩展了 Employee , 而 Employee 实 现 的 是 Comparable
class Manager extends Employee { public int compareTo(Employee other) { Manager otherManager = (Manager) other; // NO ... } ... }
这 不 符 合“ 反 对 称” 的 规 则。如 果 x 是 一 个 Employee 对 象,y 是 一 个 Manager 对 象, 调 用 x.compareTo(y) 不 会 抛 出 异 常, 它 只 是 将 x 和 y 都 作 为 雇 员 进 行 比 较。 但 是 反 过 来, y.compareTo(x) 将 会 抛 出 一 个 ClassCastException。
这 种 情 况 与 第 5 章 中 讨 论 的 equals 方 法 一 样, 修 改 的 方 式 也 一 样 , 有 两 种 不 同 的 情 况。
如 果 子 类 之 间 的 比 较 含 义 不 一 样, 那 就 属 于 不 同 类 对 象 的 非 法 比 较。 每 个 compareTo 方 法 都 应 该 在 开 始 时 进 行 下 列 检 测:
if (getClass() != other.getClass()) throw new ClassCastException();
如 果 存 在 这 样 一 种 通 用 算 法, 它 能 够 对 两 个 不 同 的 子 类 对 象 进 行 比 较, 则 应 该 在 超 类 中 提 供 一 个 compareTo 方 法,并 将 这 个 方 法 声 明 为 final 。
例 如, 假 设 不 管 薪 水 的 多 少 都 想 让 经 理 大 于 雇 员, 像 Executive 和 Secretary 这 样 的 子 类 又 该 怎 么 办 呢? 如 果 一 定 要 按 照 职 务 排 列 的 话, 那 就 应 该 在 Employee 类 中 提 供 一 个 rank 方 法,, 每 个 子 类 覆 盖 rank, 并 实 现 一 个 考 虑 rank 值 的 compareTo 方 法。
6.1.2 接口的特性
接口不是类,尤其不能使用 new 运算符实例化一个接口:
x = new Comparable(. . .); // ERROR
然而, 尽管不能构造接口的对象,却能声明接口的变量:
Comparable x; // OK
接口变量必须引用实现了接口的类对象:
x = new Employee(. . .); // OK provided Employee implements Comparable
接下来, 如同使用 instanceof检查一个对象是否属于某个特定类一样, 也可以使用 instance 检查一个对象是否实现了某个特定的接口:
if (anObject instanceof Comparable) { . . . }
与可以建立类的继承关系一样,接口也可以被扩展。这里允许存在多条从具有较高通用 性的接口到较高专用性的接口的链。例如,假设有一个称为 Moveable 的接口:
public interface Moveable { void move(double x, double y); }
然后, 可以以它为基础扩展一个叫做 Powered 的接口:
public interface Powered extends Moveable { double milesPerCallon(); }
虽然在接口中不能包含实例域或静态方法,但却可以包含常量。例如:
public interface Powered extends Moveable { double milesPerCallon(); double SPEED_LIHIT = 95; // a public static final constant }
与接口中的方法都自动地被设置为 public —样,接口中的域将被自动设为 public static final。
注释: 可以将接口方法标记为 public, 将域标记为 public static final。有些程序员出于习 惯或提高清晰度的考虑, 愿意这样做。但 Java 语言规范却建议不要书写这些多余的关键字, 本书也采纳了这个建议。
有些接口只定义了常量, 而没有定义方法。例如,在标准库中有一个 SwingConstants 就是这样一个接口, 其中只包含 NORTH、 SOUTH 和 HORIZONTAL 等常量。 任何实现 SwingConstants 接口的类都自动地继承了这些常量,并可以在方法中直接地引用 NORTH, 而不必采用 SwingConstants.NORTH 这样的繁琐书写形式。然而,这样应用接口似乎有点偏 离了接口概念的初衷, 最好不要这样使用它。
尽管每个类只能够拥有一个超类, 但却可以实现多个接口。这就为定义类的行为提供了极大的灵活性。例如, Java 程序设计语言有一个非常重要的内置接口,称为 Cloneable (将在 6.2.3 节中给予详细的讨论) 。如果某个类实现了这个 Cloneable 接口,Object 类中的 clone 方 法就可以创建类对象的一个拷贝。如果希望自己设计的类拥有克隆和比较的能力,只要实现 这两个接口就可以了: 使用逗号将实现的各个接口分隔开。
class Employee implements Cloneable, Comparable
6.1.3 接口与抽象类
如果阅读了第 5 章中有关抽象类的内容, 那就可能会产生这样一个疑问:为什么 Java 程 序设计语言还要不辞辛苦地引入接口概念? 为什么不将 Comparable 直接设计成如下所示的 抽象类。
abstract class Comparable // why not? { public abstract int compareTo(Object other); }
然后,Employee 类再直接扩展这个抽象类, 并提供 compareTo 方法的实现:
class Employee extends Comparable // why not? { public int compareTo(Object other) { . . . } }
非常遗憾, 使用抽象类表示通用属性存在这样一个问题: 每个类只能扩展于一个类。假 设 Employee 类已经扩展于一个类, 例如 Person, 它就不能再像下面这样扩展第二个类了:
class Employee extends Person, Comparable // Error
但每个类可以像下面这样实现多个接口:
class Employee extends Person implements Comparable // OK
有些程序设计语言允许一个类有多个超类, 例如 C++。我们将此特性称为多重继承 ( multiple inheritance) 。而 Java 的设计者选择了不支持多继承,其主要原因是多继承会让语言 本身变得非常复杂(如同 C++,) 效率也会降低(如同 Eiffel)。
实际上,接口可以提供多重继承的大多数好处,同时还能避免多重继承的复杂性和低效性。
C++ 注释:C++ 具有多重继承特性, 随之带来了一些诸如虚基类、控制规则和横向指针 类型转换等复杂特性 , 很少有 C++ 程序员使用多继承, 甚至有些人说:就不应该使用多 继承。也有些程序员建议只对“ 混合” 风格的继承使用多继承。 在“ 混合” 风格中, 一 个主要的基类描述父对象, 其他的基类( 因此称为混合)扮演辅助的角色。这种风格类 似于 Java 类中从一个基类派生, 然后实现若干个辅助接口。
6.1.4 静态方法
在 Java SE 8 中,允许在接口中增加静态方法。理论上讲,没有任何理由认为这是不合法 的。只是这有违于将接口作为抽象规范的初衷。
目前为止, 通常的做法都是将静态方法放在伴随类中。在标准库中, 你会看到成对出现 的接口和实用工具类, 如 Collection/Collections 或 Path/Paths。 下面来看 Paths 类, 其中只包含两个工厂方法。可以由一个字符串序列构造一个文件或 目录的路径, 如 Paths.get("jdk1.8.0", "jre", "bin"。) 在 Java SE 8 中, 可以为 Path 接口增加以 下方法:
public interface Path
{ public static Path get(String first, String... more) { return Fi1eSystems.getDefault().getPath(first, more); } ... }
这样一来, Paths 类就不再是必要的了。
不过整个 Java 库都以这种方式重构也是不太可能的, 但是实现你自己的接口时,不再需 要为实用工具方法另外提供一个伴随类。
6.1.5 默认方法
可以为接口方法提供一个默认实现。 必须用 default 修饰符标记这样一个方法。
public interface Comparable{ default int compareTo(T other) { return 0; } // By default, all elements are the same }
当然, 这并没有太大用处, 因为 Comparable 的每一个实际实现都要覆盖这个方法。不过 有些情况下, 默认方法可能很有用。 例如,在第 11 章会看到, 如果希望在发生鼠标点击事 件时得到通知,就要实现一个包含 5 个方法的接口:
public interface MouseListener { void mousedieked(MouseEvent event); void mousePressed(MouseEvent event); void mouseReleased(MouseEvent event); void mouseEntered(MouseEvent event); void mouseExited(MouseEvent event); }
大多数情况下, 你只需要关心其中的 1、2 个事件类型。在 Java SE 8 中, 可以把所有方 法声明为默认方法, 这些默认方法什么也不做。
public interface MouseListener { default void mousedieked(MouseEvent event) {} default void mousePressed(MouseEvent event) {} default void mouseReleased(MouseEvent event) {} default void mouseEntered(MouseEvent event) {} default void mouseExited(MouseEvent event) {} }
这样一来,实现这个接口的程序员只需要为他们真正关心的事件覆盖相应的监听器。 默认方法可以调用任何其他方法。例如, Collection 接口可以定义一个便利方法:
public interface Collection { int size(); // An abstract method default boolean isEmpty() { return size() = 0; } ... }
这样实现 Collection 的程序员就不用操心实现 isEmpty 方法了
注释:在 JavaAPI 中,你会看到很多接口都有相应的伴随类,这个伴随类中实现了相应接 口 的部分或所有方法,如 Collection/AbstractCollection 或 MouseListener/MouseAdapter。在 JavaSE 8 中, 这个技术已经过时。现在可以直接在接口中实现方法。
默认方法的一个重要用法是“接口演化” (interface evolution) Collection 接口为例, 这个接口作为 Java 的一部分已经有很多年了。假设很久以前你提供了这样一个类:
public class Bag implements Collection
后来, 在 JavaSE 8 中, 又为这个接口增加了一个 stream 方法。
假设 stream 方法不是一个默认方法。那么 Bag 类将不能编译, 因为它没有实现这个新方 法。为接口增加一个非默认方法不能保证“ 源代码兼容"(source compatible)。
不过, 假设不重新编译这个类,而只是使用原先的一个包含这个类的 JAR 文件。这个类 仍能正常加载,尽管没有这个新方法。程序仍然可以正常构造 Bag 实例,不会有意外发生。 ( 为接口增加方法可以保证“ 二进制兼容”)。不过, 如果程序在一个 Bag 实例上调用 stream 方法,就会出现一个 AbstractMethodError。
将方法实现为一个默认方法就可以解决这两个问题。Bag 类又能正常编译了。另外如果 没有重新编译而直接加载这个类, 并在一个 Bag 实例上调用 stream 方法, 将调用 Collection.stream 方法。
6.1.6 解决默认方法冲突
如果先在一个接口中将一个方法定义为默认方法, 然后又在超类或另一个接口中定义了 同样的方法, 会发生什么情况? 诸如 Scala 和 C++ 等语言对于解决这种二义性有一些复杂的 规则。幸运的是,Java 的相应规则要简单得多。规则如下:
1 ) 超类优先。如果超类提供了一个具体方法,同名而且有相同参数类型的默认方法会 被忽略。
2 ) 接口冲突。 如果一个超接口提供了一个默认方法,另一个接口提供了一个同名而且 参数类型(不论是否是默认参数)相同的方法, 必须覆盖这个方法来解决冲突。
下面来看第二个规则。考虑另一个包含 getName 方法的接口:
interface Named { default String getName() { return getClass() .getName() + "_" + hashCode() ; } }
如果有一个类同时实现了这两个接口会怎么样呢?
class Student implements Person, Named { ... }
类会继承 Person 和 Named 接口提供的两个不一致的 getName 方法。并不是从中选择一 个,Java 编译器会报告一个错误,让程序员来解决这个二义性。只需要在 Student 类中提供 一个 getName 方法。在这个方法中,可以选择两个冲突方法中的一个,如下所示:
class Student implements Person, Named { public String getName () { return Person.super.getName(); } ... }
现在假设 Named 接口没有为 getName 提供默认实现:
interface Named { String getName(); }
Student 类会从 Person 接口继承默认方法吗? 这好像挺有道理, 不过,Java 设计者更强 调一致性。两个接口如何冲突并不重要。如果至少有一个接口提供了一个实现,编译器就会 报告错误, 而程序员就必须解决这个二义性。
注释: 当然,如果两个接口都没有为共享方法提供默认实现, 那么就与 Java SE 8之前的 情况一样,这里不存在冲突。 实现类可以有两个选择:实现这个方法,或者干脆不实现。 如果是后一种情况,这个类本身就是抽象的。
我们只讨论了两个接口的命名冲突。现在来考虑另一种情况,一个类扩展了一个超类, 同时实现了一个接口,并从超类和接口继承了相同的方法。例如, 假设 Person 是一个类, Student 定义为:
class Student extends Person implements Named { ...}
在这种情况下, 只会考虑超类方法,接口的所有默认方法都会被忽略。在我们的例子 中, Student 从 Person 继承了 getName 方法, Named 接口是否为 getName 提供了默认实现并 不会带来什么区别。这正是“ 类优先” 规则。
“ 类优先” 规则可以确保与 Java SE 7 的兼容性。如果为一个接口增加默认方法,这对于 有这个默认方法之前能正常工作的代码不会有任何影响。
警告:千万不要让一个默认方法重新定义 Object 类中的某个方法。例如,不能为 toString 或 equals 定义默认方法, 尽管对于 List 之类的接口这可能很有吸引力, 由于“ 类优先” 规则, 这样的方法绝对无法超越 Object.toString 或 Objects.equals。
6.2 接口示例
接下来的 3 节中, 我们将给出接口的另外一些示例,可以从中了解接口的实际使用。
6.2.1 接口与回调
回调( callback) 是一种常见的程序设计模式。在这种模式中,可以指出某个特定事件发 生时应该采取的动作。例如,可以指出在按下鼠标或选择某个菜单项时应该采取什么行动。 然而,由于至此还没有介绍如何实现用户接口,所以只能讨论一些与上述操作类似,但比较 简单的情况。
在 java.swing 包中有一个 Timer 类,可以使用它在到达给定的时间间隔时发出通告。 例 如,假如程序中有一个时钟,就可以请求每秒钟获得一个通告, 以便更新时钟的表盘。
在构造定时器时,需要设置一个时间间隔,并告之定时器,当到达时间间隔时需要做些 什么操作。
如何告之定时器做什么呢? 在很多程序设计语言中,可以提供一个函数名, 定时器周期 性地调用它。 但是, 在 Java 标准类库中的类采用的是面向对象方法。它将某个类的对象传递 给定时器,然后,定时器调用这个对象的方法。由于对象可以携带一些附加的信息,所以传递一个对象比传递一个函数要灵活得多。
当然, 定时器需要知道调用哪一个方法,并要求传递的对象所属的类实现了java.awt. event 包的 ActionListener 接口。下面是这个接口:
public interface ActionListener { void actionPerformed(ActionEvent event); }
当到达指定的时间间隔时,定时器就调用 actionPerformed 方法。
假设希望每隔 10 秒钟打印一条信息“ At the tone, the time is . . .”, 然后响一声, 就应该 定义一个实现 ActionListener 接口的类,然后将需要执行的语句放在 actionPerformed方法中。
class TimePrinter implements ActionListener { public void actionPerformed(ActionEvent event) { System.out.println("At the tone, the time is " + new date();) Toolkit.getDefaultToolkit().beep(); } }
需要注意 actionPerformed 方法的 ActionEvent 参数。这个参数提供了事件的相关信息,
例如,产生这个事件的源对象。有关这方面的详细内容请参看第 8 章。在这个程序中, 事件 的详细信息并不重要, 因此, 可以放心地忽略这个参数。
接下来, 构造这个类的一个对象, 并将它传递给 Timer 构造器。
ActionListener listener = new TimePrinter(); Timer t = new Timer(10000, listener);
Timer 构造器的第一个参数是发出通告的时间间隔, 它的单位是毫秒。这里希望每隔 10 秒钟通告一次。第二个参数是监听器对象。
最后, 启动定时器:
t.start();
每隔 10 秒钟, 下列信息显示一次, 然后响一声铃。
At the tone, the time is Wed Apr 13 23:29:08 PDT 2021
程序清单 6-3 给出了定时器和监听器的操作行为。在定时器启动以后, 程序将弹出一个 消息对话框, 并等待用户点击 Ok 按钮来终止程序的执行。在程序等待用户操作的同时, 每 隔 10 秒显示一次当前的时间。
需要注意, 这个程序除了导入 javax.swing.* 和 java.util.* 外, 还通过类名导入了 javax. swing.Timer。 这 就 消 除 了 javax.swing.Timer 与 java.util.Timer 之间产生的二义性。 这里的 java. util.Timer 是一个与本例无关的类, 它主要用于调度后台任务。
API javax.swing.JOptionPane 1.2
? static void showMessageDialog(Component parent, Object message)
显示一个包含一条消息和 OK 按钮的对话框。这个对话框将位于其 parent 组件的中央。如果 parent 为 null, 对话框将显示在屏幕的中央。
API javax.swing.Timer 1.2
? Timer(int interval , ActionListener listener)
构造一个定时器, 每隔 interval 毫秒通告 listener —次。
? void start() 启动定时器一旦启动成功, 定时器将调用监听器的 actionPerformed。
? void stop() 停止定时器。一旦停止成功, 定时器将不再调用监听器的 actionPerfcmned。
API java.awt.Toolkit 1.0
? static Toolkit getDefaultToolkit() 获得默认的工具箱。 工具箱包含有关 GUI 环境的信息。
? void beep() 发出一声铃响。
6.2.2 Comparator 接口
6.1.1 节中, 我们已经了解了如何对一个对象数组排序,前提是这些对象是实现了 Comparable 接口的类的实例。 例如, 可以对一个字符串数组排序, 因为 String 类实现了 Comparable, 而 且 String.compareTo 方法可以按字典顺序比较字符串。
现在假设我们希望按长度递增的顺序对字符串进行排序,而不是按字典顺序进行排序。 肯定不能让 String 类用两种不同的方式实现 compareTo 方法---更何况,String 类也不应由 我们来修改。
要处理这种情况,Arrays.Sort 方法还有第二个版本, 有一个数组和一个比较器 ( comparator )作为参数, 比较器是实现了 Comparator 接口的类的实例。
public interface Comparator{ int compare(T first, T second); }
要按长度比较字符串,可以如下定义一个实现 Comparator 的类:
class LengthComparator implements Comparator{ public int compare(String first, String second) { return first.length() - second.length(); } }
具体完成比较时,需要建立一个实例:
Comparatorcomp = new LengthComparator(); if (conp.compare(words[i], words[j]) > 0) ...
将这个调用与 words[i].compareTo(words[j]) 做比较。这个 compare 方法要在比较器对象 上调用,而不是在字符串本身上调用。
注释: 尽管 LengthComparator 对象没有状态, 不过还是需要建立这个对象的一个实例。 我们需要这个实例来调用 compare 方法---它不是一个静态方法。
要对一个数组排序, 需要为 Arrays.sort 方法传入一个 LengthComparator 对象:
String[] friends = { "Peter", "Paul", "Mary" }; Arrays.sort(friends, new LengthComparator()):
现在这个数组可能是 ["Paul","Mary","Peter"] 或 ["Mary","Paul","Peter"]。
在 6.3 节中我们会了解, 利用 lambda 表达式可以更容易地使用 Comparator。
6.2.3 对象克隆
本节我们会讨论 Cloneable 接口,这个接口指示一个类提供了一个安全的 clone 方法。由 于克隆并不太常见,而且有关的细节技术性很强,你可能只是想稍做了解, 等真正需要时再 深入学习。
要了解克隆的具体含义,先来回忆为一个包含对象引用的变量建立副本时会发生什么。 原变量和副本都是同一个对象的引用(见图 6-1 )。这说明, 任何一个变量改变都会影响另一 个变量。
Employee original = new Employee("John Public", 50000); Employee copy = original; copy.raiseSalary(l0); // oops-also changed original
如果希望 copy 是一个新对象,它的初始状态与 original 相同, 但是之后它们各自会有自 己不同的状态, 这种情况下就可以使用 clone 方法。
Employee copy = original.clone(); copy.raiseSalary(l0); // OK original unchanged
不过并没有这么简单。clone 方法是 Object 的一个 protected 方法,这说明你的代码不能 直接调用这个方法。只有 Employee 类可以克隆 Employee 对象。这个限制是有原因的。想想 看 Object 类如何实现 clone。它对于这个对象一无所知, 所以只能逐个域地进行拷贝。 如果 对象中的所有数据域都是数值或其他基本类型,拷贝这些域没有任何问题、 但是如果对象包 含子对象的引用,拷贝域就会得到相同子对象的另一个引用,这样一来,原对象和克隆的对 象仍然会共享一些信息。
为了更直观地说明这个问题, 考虑第 4 章介绍过的 Employee 类。 图 6-2 显示了使用 Object 类的 clone 方法克隆这样一个 Employee 对象会发生什么。可以看到, 默认的克隆操作 是“ 浅拷贝”,并没有克隆对象中引用的其他对象。(这个图显示了一个共享的 Date 对象。出 于某种原因(稍后就会解释这个原因) ,这个例子使用了 Employee 类的老版本,其中的雇用 日期仍用 Date 表示。)
浅拷贝会有什么影响吗? 这要看具体情况。如果原对象和浅克隆对象共享的子对象是不 可变的, 那么这种共享就是安全的。如果子对象属于一个不可变的类, 如 String, 就 是 这 种 情况。或者在对象的生命期中, 子对象一直包含不变的常量, 没有更改器方法会改变它, 也 没有方法会生成它的引用,这种情况下同样是安全的。
不过, 通常子对象都是可变的, 必须重新定义 clone 方法来建立一个深拷贝, 同时克隆 所有子对象。在这个例子中,hireDay 域是一个 Date , 这是可变的, 所以它也需要克隆。(出 于这个原因, 这个例子使用 Date 类型的域而不是 LocalDate 来展示克隆过程。如果 hireDay 是不可变的 LocalDate 类的一个实例,就无需我们做任何处理了。)
对于每一个类,需要确定:
1 ) 默认的 clone 方法是否满足要求;
2 ) 是否可以在可变的子对象上调用 clone 来修补默认的 clone 方法;
3 ) 是否不该使用 clone。
实际上第 3 个选项是默认选项。如果选择第 1 项或第 2 项,类必须:
1 ) 实现 Cloneable 接口;
2 ) 重新定义 clone 方法,并指定 public 访问修饰符。
注释: Object 类中 clone 方法声明为 protected , 所以你的代码不能直接调用 anObject. clone()。 但是, 不是所有子类都能访问受保护方法吗? 不是所有类都是 Object 的子类 吗? 幸运的是, 受保护访问的规则比较微妙(见第 5 章)。子类只能调用受保护的 clone 方法来克隆它自己的对象。 必须重新定义 clone 为 public 才能允许所有方法克隆对象。
在这里, Cloneable 接口的出现与接口的正常使用并没有关系。具体来说,它没有指定 clone 方法,这个方法是从 Object 类继承的。这个接口只是作为一个标记,指示类设计者了 解克隆过程。对象对于克隆很“ 偏执”, 如果一个对象请求克隆, 但没有实现这个接口, 就 会生成一个受査异常。
注释:Cloneable 接口是 Java 提供的一组标记接口 ( tagging interface ) 之一。(有些程序员 称之为记号接口 ( markcer interface))。应该记得,Comparable 等接口的通常用途是确保一个类实现一个或一组特定的方法。标记接口不包含任何方法; 它唯一的作用就是允许在类型查询中使用 instanceof:
if (obj instanceof Cloneable) . . .
建议你自己的程序中不要使用标记接口。
即使 clone 的默认(浅拷贝)实现能够满足要求, 还是需要实现 Cloneable 接口, 将 clone 重新定义为 public,再调用 super.clone() 。下面给出一个例子:
class Employee implements Cloneable { // raise visibility level to public, change return type public Employee clone() throws CloneNotSupportedException { return (Employee) super.clone(); } ... }
注释: 在 Java SE 1.4 之前, clone 方法的返回类型总是 Object, 而现在可以为你的 clone 方法指定正确的返回类型。这是协变返回类型的一个例子(见第 5 章)。
与 Object.clone 提供的浅拷贝相比, 前面看到的 clone 方法并没有为它增加任何功能。这里 只是让这个方法是公有的。要建立深拷贝,还需要做更多工作,克隆对象中可变的实例域。
下面来看创建深拷贝的 clone 方法的一个例子:
class Employee implements Cloneable { ... public Employee clone() throws CloneNotSupportedExcepti on { // call Object,clone() Employee cloned = (Employee) super.clone() ; // clone mutable fields cloned.hireDay = (Date) hireDay. clone(); return cloned; } }
如果在一个对象上调用 clone, 但这个对象的类并没有实现 Cloneable 接口, Object 类 的 clone 方法就会拋出一个 CloneNotSupportedException。 当然,Employee 和 Date 类实现了 Cloneable 接口,所以不会抛出这个异常。 不过, 编译器并不了解这一点,因此,我们声明了 这个异常:
public Employee done() throws CloneNotSupportedException
捕获这个异常是不是更好一些?
public Employee clone() { try { Employee cloned = (Employee) super.clone(); } catch (CloneNotSupportedException e) { return null; } // this won't happen, since we are Cloneable }
这非常适用于 final 类。否则, 最好还是保留 throws 说明符。这样就允许子类在不支持 克隆时选择抛出一个 CloneNotSupportedException。
必须当心子类的克隆。例如,一旦为 Employee 类定义了 clone 方法,任何人都可以用它 来克隆 Manager 对象。Employee 克隆方法能完成工作吗? 这取决于 Manager 类的域。在这里 是没有问题的, 因为 bonus 域是基本类型。但是 Manager 可能会有需要深拷贝或不可克隆的 域。不能保证子类的实现者一定会修正 clone 方法让它正常工作。出于这个原因,在 Object 类中 clone 方法声明为 protected。不过, 如果你希望类用户调用 clone, 就不能这样做。
要不要在自己的类中实现 clone 呢? 如果你的客户需要建立深拷贝,可能就需要实现这 个方法。有些人认为应该完全避免使用 clone, 而实现另一个方法来达到同样的目的。clone 相当笨拙, 这一点我们也同意,不过如果让另一个方法来完成这个工作, 还是会遇到同样的 问题。毕竟, 克隆没有你想象中那么常用。标准库中只有不到 5% 的类实现了clone。
程序清单 6-4 中的程序克隆了 Employee 类(程序清单 6-5 ) 的一个实例,然后调用两个 更改器方法。raiseSalary 方法会改变 salary 域的值, 而 setHireDay 方法改变 hireDay 域的状态。这两个更改器方法都不会影响原来的对象, 因为 clone 定义为建立一个深拷贝。
注释: 所有数组类型都有一个 public 的 clone 方法, 而不是 protected。可以用这个方法 建立一个新数组, 包含原数组所有元素的副本。例如:
int[] luckyNumbers = { 2, 3, 5, 7, 11, 13 }; int[] cloned = luckyNumbers.clone(); cloned[5] = 12; // doesn't change luckyNumbers[5]
注释:卷 II 的第 2 章将展示另一种克隆对象的机制, 其中使用了 Java 的对象串行化特性。 这个机制很容易实现,而且很安全,但效率不高。
6.3 lambda 表达式
现在可以来学习 lambda 表达式, 这是这些年来 Java 语言最让人激动的一个变化。你会 了解如何使用 lambda 表达式采用一种简洁的语法定义代码块, 以及如何编写处理 lambda 表 达式的代码。
6.3.1 为什么引入 lambda 表达式
lambda 表达式是一个可传递的代码块, 可以在以后执行一次或多次。具体介绍语法(以及解释这个让人好奇的名字)之前,下面先退一步,观察一下我们在 Java 中的哪些地方用过 这种代码块。
在 6.2.1节中, 你已经了解了如何按指定时间间隔完成工作。将这个工作放在一个 ActionListener 的 actionPerformed方法中:
class Worker implements ActionListener { public void actionPerformed(ActionEvent event) { // do some work } }
想要反复执行这个代码时,可以构造 Worker 类的一个实例。然后把这个实例提交到一 个 Timer 对象。这里的重点是 actionPerformed方法包含希望以后执行的代码。
或者可以考虑如何用一个定制比较器完成排序。如果想按长度而不是默认的字典顺序对 字符串排序,可以向 sort 方法传入一个 Comparator 对象:
class LengthComparator implements Comparator{ public int compare(String first, String second) { return first.length() - second.length(); } } ... Arrays.sort(strings, new LengthComparator());
compare 方法不是立即调用。 实际上, 在数组完成排序之前, sort 方法会一直调用 compare 方法,只要元素的顺序不正确就会重新排列元素。将比较元素所需的代码段放在 sort 方法中, 这个代码将与其余的排序逻辑集成(你可能并不打算重新实现其余的这部分逻辑)。
这两个例子有一些共同点,都是将一个代码块传递到某个对象(一个定时器, 或者一个 sort 方法) 。这个代码块会在将来某个时间调用。
到目前为止,在 Java 中传递一个代码段并不容易, 不能直接传递代码段。 Java 是一种面向对象语言,所以必须构造一个对象,这个对象的类需要有一个方法能包含所需的代码。
在其他语言中,可以直接处理代码块。Java 设计者很长时间以来一直拒绝增加这个特性。 毕竟,Java 的强大之处就在于其简单性和一致性。如果只要一个特性能够让代码稍简洁一些, 就把这个特性增加到语言中, 这个语言很快就会变得一团糟,无法管理。 不过, 在另外那些 语言中,并不只是创建线程或注册按钮点击事件处理器更容易;它们的大部分 API 都更简单、 更一致而且更强大。在 Java 中, 也可以编写类似的 API利用类对象实现特定的功能,不过这 种 API 使用可能很不方便。
就现在来说,问题已经不是是否增强 Java 来支持函数式编程,而是要如何做到这一点。 设计者们做了多年的尝试,终于找到一种适合 Java 的设计。 下一节中,你会了解 Java SE 8 中如何处理代码块。
6.3.2 lambda 表达式的语法
再来考虑上一节讨论的排序例子。我们传入代码来检查一个字符串是否比另一个字符串 短。这里要计算:
first.length() - second.length()
first 和 second 是什么? 它们都是字符串。Java 是一种强类型语言,所以我们还要指定它们的类型:
(String first, String second)
-> first.length() - second.length()
这 就 是 你 看 到 的 第 一 个 表 达 式。lambda 表达式就是一个代码块, 以及必须传入代码的变量规范。
为什么起这个名字呢? 很多年前,那时还没有计算机,逻辑学家 Alonzo Church 想要形式化地表示能有效计算的数学函数。(奇怪的是, 有些函数已经知道是存在的,但是没有人知 道该如何计算这些函数的值。) 他使用了希腊字母 lambda ( λ ) 来 标 记 参 数。 如 果 他 知 道 Java API, 可能就会写为
λfirst.λsecond .first.length() - second.length()
注释: 为什么是字母 λ ? Church 已经把字母表里的所有其他字母都用完了吗? 实际上, 权威的《数学原理》一书中就使用重音符 ^ 来表示自由变量, 受此启发,Church 使用大 写 lambda ( Λ ) 表示参数。 不过, 最后他还是改为使用小写的 lambda( λ ) 。 从那以后, 带参数变量的表达式就被称为 lambda 表达式。
你已经见过 Java 中的一种 lambda 表达式形式:参数, 箭头(->) 以及一个表达式。如 果代码要完成的计算无法放在一个表达式中,就可以像写方法一样,把这些代码放在 {}中, 并包含显式的 return语句。例如:
(String first, String second) -> { if (first.length() < second.length()) return -1; else if (first.length() > second.length()) return 1; else return 0; }
即使 lambda 表达式没有参数, 仍然要提供空括号,就像无参数方法一样:
() -> { for (int i = 100;i >= 0;i-- ) System.out.println(i); }
如果可以推导出一个 lambda 表达式的参数类型,则可以忽略其类型。例如:
Comparatorcomp = (first, second) // Same as (String first, String second) -> first.length() - second.length();
在这里, 编译器可以推导出 first 和 second 必然是字符串,因为这个 lambda 表达式将赋 给一个字符串比较器。(下一节会更详细地分析这个赋值。)
如果方法只有一个参数, 而且这个参数的类型可以推导得出,那么甚至还可以省略小括号:
ActionListener listener = event -> System.out.println("The time is " + new Date()"); // Instead of (event) -> . . . or (ActionEvent event) -> . . .
无需指定 lambda 表达式的返回类型。lambda 表达式的返回类型总是会由上下文推导得 出。例如,下面的表达式
(String first, String second) -> first.length() - second.length()
可以在需要 int 类型结果的上下文中使用。
注释:如果一个 lambda 表达式只在某些分支返回一个值, 而在另外一些分支不返回值, 这是不合法的。例如,(int x)-> { if(x >= 0) return 1; } 就不合法。
6.3.3 lambda 函数式接口
前 面 已 经 讨 论 过, Java 中 已 经 有 很 多 封 装 代 码 块 的 接 口, 如 ActionListener 或 Comparator。lambda 表达式与这些接口是兼容的,
对于只有一个抽象方法的接口, 需要这种接口的对象时, 就可以提供一个 lambda 表达式。这种接口称为函数式接口 ( functional interface )。
注 释: 你可能想知道为什么函数式接口必须有一个抽象方法。不是接口中的所有方法都是抽象的吗? 实际上, 接口完全有可能重新声明 Object 类的方法, 如 toString 或 clone, 这些声明有可能会让方法不再是抽象的。( Java API 中的一些接口会重新声明 Object 方法 来附加 javadoc 注释。 Comparator API就是这样一个例子。) 更重要的是, 正如 6.1.5 节所 述, 在 JavaSE 8 中, 接口可以声明非抽象方法。
为了展示如何转换为函数式接口,下面考虑 Arrays.sort 方法。它的第二个参数需要一个 Comparator 实例, Comparator 就是只有一个方法的接口, 所以可以提供一个 lambda 表达式:
Arrays.sort (words,
(first, second) -> first.length() - second.length()) ;
在底层, Arrays.sort 方法会接收实现了 Comparator 的某个类的对象。 在这个对象上调用 compare 方法会执行这个 lambda 表达式的体。这些对象和类的管理完全取决于具体实现, 与使用传统的内联类相比,这样可能要高效得多。最好把 lambda 表达式看作是一 个函数,而不是一个对象, 另外要接受 lambda 表达式可以传递到函数式接口。
lambda 表达式可以转换为接口, 这一点让 lambda 表达式很有吸引力。具体的语法很简短。下面再来看一个例子:
Timer t = new Timer(1000, event -> { System.out.println("At the tone, the time is " + new Date()); Toolkit.getDefaultToolkit().beep(); });
与使用实现了 ActionListener 接口的类相比, 这个代码可读性要好得多。
实际上,在 Java 中, 对 lambda 表达式所能做的也只是能转换为函数式接口。在其他支持函数字面量的程序设计语言中,可以声明函数类型(如(String, String) -> int)、 声明这些类 型的变量,还可以使用变量保存函数表达式。不过,Java 设计者还是决定保持我们熟悉的接口概念, 没有为 Java语言增加函数类型。
注释:甚至不能把lambda 表达式赋值给类型为 Object 的变量,Object 不是一个函数式接口。
Java API 在java.util.function 包中定义了很多非常通用的函数式接口。其中一个接口 BiFunction
BiFunction<String, String, Integer〉comp
= (first, second) -> first.length() - second.length();
不过,这对于排序并没有帮助。没有哪个 Arrays.sort 方法想要接收一个 BiFunction。如 果你之前用过某种函数式程序设计语言,可能会发现这很奇怪。不过,对于 Java 程序员而 言,这非常自然。类似 Comparator 的接口往往有一个特定的用途, 而不只是提供一个有指定参数和返回类型的方法。Java SE 8 沿袭了这种思路。想要用 lambda 表达式做某些处理,还 是要谨记表达式的用途,为它建立一个特定的函数式接口。
java.util.function 包中有一个尤其有用的接口 Predicate:
public interface Predicate{ boolean test(T t); // Additional default and static methods }
ArrayList 类有一个 removelf 方法, 它的参数就是一个 Predicate。这个接口专门用来传递 lambda 表达式。例如,下面的语句将从一个数组列表删除所有 null 值:
list.removelf(e -> e == null);
6.3.4 方法引用
有时, 可能已经有现成的方法可以完成你想要传递到其他代码的某个动作。例如, 假设你希望只要出现一个定时器事件就打印这个事件对象。 当然,为此也可以调用:
Timer t = new Timer(1000, event -> System.out.println(event)):
但是,如果直接把 println 方法传递到 Timer 构造器就更好了。具体做法如下:
Timer t = new Timer(1000, System.out::println);
表达式 System.out::println 是一个方法引用( method reference ), 它等价于 lambda 表达式 x 一> System.out.println(x) 。
再来看一个例子, 假设你想对字符串排序, 而不考虑字母的大小写。可以传递以下方法 表达式:
Arrays.sort(strings,String::conpareToIgnoreCase)
从这些例子可以看出, 要用:: 操作符分隔方法名与对象或类名。主要有 3 种情况:
? object::instanceMethod
? Class::static Method
? Class::instanceMethod
在前 2 种情况中,方法引用等价于提供方法参数的 lambda 表达式。前面已经提到, System.out::println 等价于 x -> System.out.println(x)。 类似地,Math::pow 等价于(x,y) -> Math.pow(x, y)。
对于第 3 种情况, 第 1 个参数会成为方法的目标。例如,String::compareToIgnoreCase 等 同于 (x, y) -> x.compareToIgnoreCase(y)。
注释:如果有多个同名的重载方法, 编译器就会尝试从上下文中找出你指的那一个方法。 例如, Math.max 方法有两个版本, 一个用于整数, 另一个用于 double 值。选择哪一个版 本取决于 Math::max 转换为哪个函数式接口的方法参数。 类似于 lambda 表达式,方法引用不能独立存在,总是会转换为函数式接口的实例。
可以在方法引用中使用 this 参数。例如,this::equals 等同于 x -> this.equals(x)。 使用 super 也是合法的。下面的方法表达式
super::instanceMethod
使用 this 作为目标,会调用给定方法的超类版本。
为了展示这一点,下面给出一个假想的例子:
class Greeter { public void greet() { System.out.println("Hello, world!"); } } class TimedCreeter extends Greeter { public void greet() { Timer t = new Timer(1000, super::greet); t.start(); } }
TimedGreeter.greet 方法开始执行时,会构造一个 Timer, 它会在每次定时器滴答时执行 super::greet 方法。这个方法会调用超类的 greet 方法。
6.3.5 构造器引用
构造器引用与方法引用很类似,只不过方法名为 new。例如,Person::new 是 Person 构造器的一个引用。哪一个构造器呢? 这取决于上下文。假设你有一个字符串列表。可以把它转换为一个 Person 对象数组,为此要在各个字符串上调用构造器,调用如下:
ArrayListnames = . . .; Stream stream = names.stream().map(Person::new); List people = stream.col1ect(Col1ectors.toList());
我们将在卷 II 的第 1 章讨论 stream、 map 和 collect 方法的详细内容。就现在来说,重 点是 map 方法会为各个列表元素调用 Person(String) 构造器。如果有多个 Person 构造器, 编 译器会选择有一个 String 参数的构造器, 因为它从上下文推导出这是在对一个字符串调用构造器。
可以用数组类型建立构造器引用。例如, int[]::new 是一个构造器引用,它有一个参数: 即数组的长度。这等价于 lambda 表达式 x -> new int[x]。
Java 有一个限制,无法构造泛型类型 T 的数组。数组构造器引用对于克服这个限制很有用。表达式 new T[n] 会产生错误,因为这会改为 new Object[n] 。对于开发类库的人来说,这 是一个问题。例如,假设我们需要一个 Person 对象数组。Stream 接口有一个 toArray 方法可 以返回 Object 数组:
Object[] people = stream.toArray();
不过,这并不让人满意。用户希望得到一个 Person 引用数组,而不是 Object 引用数组。 流库利用构造器引用解决了这个问题。可以把 Person[]::new 传入 toArray 方法:
Person[] people = stream.toArray(Person[]::new);
toArray方法调用这个构造器来得到一个正确类型的数组。然后填充这个数组并返回。
6.3.6 变量作用域
通常, 你可能希望能够在 lambda 表达式中访问外围方法或类中的变量。考虑下面这个 例子:
public static void repeatMessage(String text, int delay) { ActionListener listener = event -> { System.out.println(text); Toolkit.getDefaultToolkit().beep(); }; new Timer(delay, listener).start(); }
来看这样一个调用:
repeatMessage("Hello", 1000); // Prints Hello every 1,000 milliseconds
现在来看 lambda 表达式中的变量 text。注意这个变量并不是在这个 lambda 表达式中定 义的。实际上,这是 repeatMessage 方法的一个参数变量。
如果再想想看, 这里好像会有问题, 尽管不那么明显。lambda 表达式的代码可能会在 repeatMessage 调用返回很久以后才运行,而那时这个参数变量已经不存在了。 如何保留 text 变量呢?
要了解到底会发生什么,下面来巩固我们对 lambda 表达式的理解 lambda 表达式有 3 个部分:
1 ) 一个代码块;
2 ) 参数;
3 ) 自由变量的值, 这是指非参数而且不在代码中定义的变量。
在我们的例子中, 这个 lambda 表达式有 1 个自由变量 text。表示 lambda 表达式的数据 结构必须存储自由变量的值,在这里就是字符串 "Hello"。我们说它被 lambda 表达式捕获 (下面来看具体的实现细节。 例如,可以把一个 lambda 表达式转换为包含一个方法的对象,这样自由变量的值就会复制到这个对象的实例变量中。)
注释: 关于代码块以及自由变量值有一个术语: 闭包(closure) 。如果有人吹嘘他们的语言有闭包,现在你也可以自信地说 Java 也有闭包。在 Java 中, lambda 表达式就是闭包。
可以看到,lambda 表达式可以捕获外围作用域中变量的值。 在 Java 中,要确保所捕获 的值是明确定义的,这里有一个重要的限制。在 lambda 表达式中, 只能引用值不会改变的变量。例如, 下面的做法是不合法的:
public static void countDown(int start, int delay) { ActionListener listener = event -> { start-- ; // Error: Can't mutate captured variable System.out.println(start); }; new Timer(delay, listener).start(); }
之所以有这个限制是有原因的。如果在 lambda 表达式中改变变量, 并发执行多个动作时就会不安全。对于目前为止我们看到的动作不会发生这种情况,不过一般来讲,这确实是 一个严重的问题。关于这个重要问题的更多内容参见第 14 章。
另外如果在 lambda 表达式中引用变量, 而这个变量可能在外部改变,这也是不合法的。 例如,下面就是不合法的:
public static void repeat(String text, int count) { for (int i = 1; i <= count; i++) { ActionListener listener = event -> { System.out.pnrintln(i + ": " + text); // Error: Cannot refer to changing i }; new Timer(1000, listener).start(); } }
这里有一条规则:lambda 表达式中捕获的变量必须实际上是最终变量 ( effectivelyfinal) 。实际上的最终变量是指, 这个变量初始化之后就不会再为它赋新值。在这里,text 总是指示 同一个 String 对象,所以捕获这个变量是合法的。不过,i 的值会改变,因此不能捕获 i 。
lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在 lambda 表达式中声明与一个局部变量同名的参数或局部变量是不合法的。
Path first = Paths.get("/usr/bin"); Couparatorcomp = (first, second) -> first.length() - second.length(); //Error: Variable first already defined
在方法中,不能有两个同名的局部变量, 因此, lambda 表达式中同样也不能有同名的局部变量。
在一个 lambda 表达式中使用 this 关键字时, 是指创建这个 lambda 表达式的方法的 this 参数。 例如,考虑下面的代码:
public class Application() { public void init() { ActionListener listener = event -> { System.out.println(this.toString()); ... } ... } }
表达式 this.toString() 会调用 Application 对象的 toString方法, 而不是 ActionListener 实例的方法。在 lambda 表达式中, this 的使用并没有任何特殊之处。lambda 表达式的作用域嵌套在 init 方法中,与出现在这个方法中的其他位置一样, lambda 表达式中 this 的含义并没有变化。
6.3.7 处理 lambda 表达式
到目前为止, 你已经了解了如何生成 lambda 表达式, 以及如何把 lambda 表达式传递到 需要一个函数式接口的方法。下面来看如何编写方法处理 lambda 表达式。
使用 lambda 表达式的重点是延迟执行 (deferred execution ) 。毕竟, 如果想耍立即执行代 码,完全可以直接执行, 而无需把它包装在一个lambda 表达式中。之所以希望以后再执行 代码, 这有很多原因, 如:
? 在一个单独的线程中运行代码;
? 多次运行代码;
? 在算法的适当位置运行代码(例如, 排序中的比较操作);
? 发生某种情况时执行代码(如, 点击了一个按钮, 数据到达, 等等;)
? 只在必要时才运行代码。
下面来看一个简单的例子。假设你想要重复一个动作 n 次。 将这个动作和重复次数传递 到一个 repeat 方法:
repeat(10, 0 -> System.out.println("Hello, World!"));
要接受这个 lambda 表达式, 需要选择(偶尔可能需要提供)一个函数式接口。 表 6-1 列 出了 Java API 中提供的最重要的函数式接口。在这里, 我们可以使用 Runnable 接口:
public static void repeat(int n, Runnable action) { for (int i = 0; i < n; i++) action.run(); }
需要说明,调用 action.run() 时会执行这个 lambda 表达式的主体。
现在让这个例子更复杂一些。我们希望告诉这个动作它出现在哪一次迭代中。 为此,需 要选择一个合适的函数式接口,其中要包含一个方法, 这个方法有一个 int 参数而且返回类 型为 void: 处理 int 值的标准接口如下:
public interface IntConsumer { void accept(int value); }
下面给出 repeat 方法的改进版本:
public static void repeat(int n, IntConsumer action) { for (int i = 0; i < n; i++) action.accept(i); }
可以如下调用它:
repeat(10, i-> System.out.println("Countdown: " + (9 - i));
表 6-2 列出了基本类型 int、 long 和 double 的 34 个可能的规范。 最好使用这些特殊化规范来减少自动装箱。出于这个原因, 我在上一节的例子中使用了 IntConsumer 而不是 Consumer
提示: 最好使用表 6-1 或表 6-2 中的接口。 例如, 假设要编写一个方法来处理满足 某个特定条件的文件。 对此有一个遗留接口 java.io.FileFilter, 不过最好使用标准的 Predicate , 只有一种情况下可以不这么做, 那就是你已经有很多有用的方法可以生 成 FileFilter 实例。
注释:大多数标准函数式接口都提供了非抽象方法来生成或合并函数。 例如, Predicate. isEqual(a) 等同于 a::equals, 不过如果 a 为 null 也能正常工作。已经提供了默认方法 and、 or 和 negate 来合并谓词。 例如, Predicate.isEqual(a).or(Predicate.isEqual(b)) 就等同于 x -> a.equals(x) || b.equals(x)。
注释: 如果设计你自己的接口,其中只有一个抽象方法,可以用 @FunctionalInterface 注解来标记这个接口。这样做有两个优点。 如果你无意中增加了另一个非抽象方法, 编译器会产生一个错误消息。 另外 javadoc 页里会指出你的接口是一个函数式接口。
并不是必须使用注解。根据定义,任何有一个抽象方法的接口都是函数式接口。不 过使用 @FunctionalInterface 注解确实是一个很好的做法。
6.3.8 再谈 Comparator
Comparator 接口包含很多方便的静态方法来创建比较器。 这些方法可以用于 lambda 表 达式或方法引用。
静态 comparing 方法取一个“ 键提取器” 函数, 它将类型 T 映射为一个可比较的类型 ( 如 String )。对要比较的对象应用这个函数, 然后对返回的键完成比较。例如,假设有一个 Person 对象数组,可以如下按名字对这些对象排序:
Arrays.sort(people, Comparator.comparing(Person::getName));
与手动实现一个 Compamtor 相比, 这当然要容易得多。另外, 代码也更为清晰, 因为显 然我们都希望按人名来进行比较。
可以把比较器与 thenComparing 方法串起来。例如,
Arrays.sort(people ,
Comparator.comparing(Person::getlastName)
.thenComparing::getFirstName));
如果两个人的姓相同, 就会使用第二个比较器。
这些方法有很多变体形式。可以为 comparing 和 thenComparing 方法提取的键指定一个 比较器。例如,可以如下根据人名长度完成排序:
Arrays.sort(people, Comparator.comparing(Person::getName,
(s, t) -> Integer.compare(s.1ength(), t.length())));
另外, comparing 和 thenComparing 方法都有变体形式,可以避免 int、 long 或 double 值 的装箱。要完成前一个操作, 还有一种更容易的做法:
Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));
如果键函数可以返回 null, 可 能 就 要 用 到 nullsFirst 和 nullsLast 适配器。这些静态方法会修改现有的比较器,从而在遇到 null 值时不会抛出异常, 而是将这个值标记为小于或 大于正常值。例如, 假设一个人没有中名时 getMiddleName 会返回一个 null, 就 可 以 使 用 Comparator.comparing(Person::getMiddleName(), Comparator.nullsFirst(… )。
nullsFirst 方法需要一个比较器,在这里就是比较两个字符串的比较器。naturalOrder 方法可以 为任何实现了 Comparable 的类建立一个比较器。在这里,Comparator.
Arrays.sort(people, comparing(Person::getMiddleName , nulIsFirst(naturalOrder())));
静态 reverseOrder 方法会提供自然顺序的逆序。要让比较器逆序比较, 可以使用 reversed 实例方法。例如 naturalOrder().reversed() 等同于 reverseOrder()。
6.4 内部类
内部类( inner class) 是定义在另一个类中的类。为什么需要使用内部类呢? 其主要原因有以下三点:
? 内部类方法可以访问该类定义所在的作用域中的数据, 包括私有的数据。
? 内部类可以对同一个包中的其他类隐藏起来。
? 当想要定义一个回调函数且不想编写大量代码时,使用匿名 (anonymous) 内部类比较便捷。
我们将这个比较复杂的内容分几部分介绍。
? 在 6.4.1 节中, 给出一个简单的内部类, 它将访问外围类的实例域。
? 在 6.4.2 节中,给出内部类的特殊语法规则。
? 在 6.4.3节中,领略一下内部类的内部, 探讨一下如何将其转换成常规类。过于拘谨 的读者可以跳过这一节。
? 在 6.4.4 节中,讨论局部内部类, 它可以访问外围作用域中的局部变量。
? 在 6.4.5 节中,介绍匿名内部类, 说明在 Java 有 lambda 表达式之前用于实现回调的基本方法。
? 最后在 6.4.6 节中,介绍如何将静态内部类嵌套在辅助类中。
C++ 注释:C++ 有嵌套类。一个被嵌套的类包含在外围类的作用域内。下面是一个典型 的例子,一个链表类定义了一个存储结点的类和一个定义迭代器位置的类。
class LinkedList { public: class Iterator // a nested class { public: void insert(int x); int erase(); ... }; ... private: class Link // a nested class { public: Link* next; int data; }; ... };
嵌套是一种类之间的关系, 而不是对象之间的关系。一个 LinkedList 对象并不包含 Iterator 类型或 Link 类型的子对象。
嵌套类有两个好处:命名控制和访问控制。由于名字 Iterator 嵌套在 LinkedList 类 的内部, 所以在外部被命名为 LinkedList::Iterator,这样就不会与其他名为 Iterator 的类 发生冲突。在 Java 中这个并不重要, 因为 Java 包已经提供了相同的命名控制。 需要注 意的是, Link 类位于 LinkedList 类的私有部分, 因此,Link 对其他的代码均不可见。鉴于此情况, 可以将 Link 的数据域设计为公有的, 它仍然是安全的。这些数据域只能被 LinkedList 类 (具有访问这些数据域的合理需要)中的方法访问, 而不会暴露给其他的代 码。在 Java 中, 只有内部类能够实现这样的控制。
然而, Java 内部类还有另外一个功能,这使得它比 C++ 的嵌套类更加丰富, 用途更 加广泛。 内部类的对象有一个隐式引用, 它引用了实例化该内部对象的外围类对象。通 过这个指针, 可以访问外围类对象的全部状态。在本章后续内容中, 我们将会看到有关 这个 Java 机制的详细介绍。
在 Java 中,static 内部类没有这种附加指针, 这样的内部类与 C++ 中的嵌套类很相似。
6.4.1 使用内部类访问对象状态
内部类的语法比较复杂。鉴于此情况, 我们选择一个简单但不太实用的例子说明内部类 的使用方式。下面将进一步分析 TimerTest 示例, 并抽象出一个 TalkingClock 类。构造一个 语音时钟时需要提供两个参数:发布通告的间隔和开关铃声的标志。
public class TalkingClock { private int interval: private boolean beep; public TalkingClock(int interval, boolean beep) { . . . } public void start() {...} public class TimePrinter implements ActionListener // an inner class { ... } }
需要注意, 这里的 TimePrinter 类位于 TalkingClock类内部。这并不意味着每个 TalkingClock 都有一个 TimePrinter 实例域 , 如前所示, TimePrinter 对象是由 TalkingClock类的方法构造。
下面是 TimePrinter 类的详细内容。需要注意一点,actionPerformed 方法在发出铃声之前 检查了 beep 标志。
public class TimePrinter implements ActionListener { public void actionPerformed(ActionEvent event) { System.out.println("At the tone, the time is " + new Date()); if (beep) Toolkit.getDefaultToolkit() .beep(); } }
令人惊讶的事情发生了。 TimePrinter 类没有实例域或者名为 beep 的变量,取而代之的是 beep 引用了创建 TimePrinter 的 TalkingClock 对象的域。 这是一种创新的想法。从传统意义 上讲,一个方法可以引用调用这个方法的对象数据域。内部类既可以访问自身的数据域,也可以访问创建它的外围类对象的数据域.
为了能够运行这个程序, 内部类的对象总有一个隐式引用, 它指向了创建它的外部类对象。如图 6-3 所示。
这个引用在内部类的定义中是不可见的。然而, 为了说明这个概念, 我们将外围类对象 的引用称为 outer。于是 actionPerformed 方法将等价于下列形式:
public void actionPerformed(ActionEvent event) { System.out.printlnC'At the tone, the time is " + new Date()); if (outer.beep) Toolkit.getDefaultToolkit().beep(); }
外围类的引用在构造器中设置。编译器修改了所有的内部类的构造器, 添加一个外围类引用的参数。因为 TimePrinter 类没有定义构造器,所以编译器为这个类生成了一个默认的构造器,其代码如下所示:
public TimePrinter(TalkingGock clock) // automatically generated code { outer = clock; }
请再注意一下, outer 不是 Java 的关键字。我们只是用它说明内部类中的机制。
当在 start 方法中创建了 TimePrinter 对象后, 编译器就会将 this 引用传递给当前的语音时钟的构造器:
ActionListener listener = new TimePrinter(this); // parameter automatically added
程序清单 6-7 给出了一个测试内部类的完整程序。下面我们再看一下访问控制。如果有 一个 TimePrinter 类是一个常规类,它就需要通过 TalkingClock 类的公有方法访问 beep 标志, 而使用内部类可以给予改进, 即不必提供仅用于访问其他类的访问器。
注释: TimePrinter 类声明为私有的。这样一来, 只有 TalkingClock 的方法才能够构造 TimePrinter 对象。只有内部类可以是私有类,而常规类只可以具有包可见性,或公有可见性。
6.4.2 内部类的特殊语法规则
在上一节中, 已经讲述了内部类有一个外围类的引用 outer。事实上,使用外围类引用的 正规语法还要复杂一些。表达式
OuterClass.this
表示外围类引用。例如,可以像下面这样编写 TimePrinter 内部类的 actionPerformed方法:
public void actionPerformed(ActionEvent event) { ... if (TalkingClock.this.beep) Toolkit.getDefaultToolkit().beep(); }
反过来,可以采用下列语法格式更加明确地编写内部对象的构造器:
outerObject.new InnerClass {construction parameters)
例如,
ActionListener listener = this.new TimePrinter();
在这里,最新构造的 TimePrinter 对象的外围类引用被设置为创建内部类对象的方法中的 this 引用。这是一种最常见的情况。通常,this 限定词是多余的。不过,可以通过显式地命名 将外围类引用设置为其他的对象。例如, 如果 TimePrinter 是一个公有内部类,对于任意的语 音时钟都可以构造一个 TimePrinter:
TalkingClock jabberer = new Ta1kingClock(1000, true); TalkingClock.TimePrinter listener = jabberer.new TimePrinter();
需要注意, 在外围类的作用域之外,可以这样引用内部类:
OuterClass.InnerClass
注释: 内部类中声明的所有静态域都必须是 final。原因很简单。我们希望一个静态域只有一个实例, 不过对于每个外部对象, 会分别有一个单独的内部类实例。如果这个域不 是 final , 它可能就不是唯一的。
内部类不能有 static 方法。Java 语言规范对这个限制没有做任何解释。也可以允许有 静态方法,但只能访问外围类的静态域和方法。显然,Java 设计者认为相对于这种复杂 性来说, 它带来的好处有些得不偿失。
6.4.3 内部类是否有用、必要和安全
当在 Java 1.1 的 Java语言中增加内部类时,很多程序员都认为这是一项很主要的新特性, 但这却违背了 Java 要比 C++ 更加简单的设计理念。内部类的语法很复杂(可以看到,稍后介绍的匿名内部类更加复杂 )。它与访问控制和安全性等其他的语言特性的没有明显的关联。
由于增加了一些看似优美有趣, 实属没必要的特性,似乎 Java 也开始走上了许多语言饱受折磨的毁灭性道路上。
我们并不打算就这个问题给予一个完整的答案。 内部类是一种编译器现象, 与虚拟机无关。编译器将会把内部类翻译成用 $ (美元符号)分隔外部类名与内部类名的常规类文件, 而 虚拟机则对此一无所知。
例如, 在 TalkingClock 类内部的 TimePrinter 类将被翻译成类文件 TalkingClock$TimePrinter.class。为了能够看到执行的效果, 可以做一下这个实验: 运行第 5 章中的程序 ReflectionTest , 并将类 TalkingClock$TimePrinter 传递给它进行反射。也可以选择简单地使用 javap, 如下所示:
javap -private ClassName
注释: 如果使用 UNIX, 并以命令行的方式提供类名,就需要记住将 $ 字符进行转义 也就是说, 应该按照下面这种格式或 javap 程序运行 ReflectionTest 程序:
java reflection.ReflectionTest innerClass.TalkingClock\$TimePrinter 或 javap -private innerClass.TalkingClock\$TimePrinter
这时会看到下面的输出结果:
public class TalkingClock$TimePrinter { public TalkingClock$TimePrinter(TalkingCtock); public void actionPerformed(java.awt.event.ActionEvent); final TalkingClock this$0(); }
可以清楚地看到, 编译器为了引用外围类, 生成了一个附加的实例域 this$0 (名字 this$0 是由编译器合成的,在自己编写的代码中不能够引用它)。另外,还可以看到构造器的 TalkingClock 参数。
如果编译器能够自动地进行转换, 那么能不能自己编写程序实现这种机制呢? 让我们 试试看。 将 TimePrinter 定义成一个常规类, 并把它置于 TalkingClock 类的外部。 在构造 TimePrinter 对象的时候, 将创建该对象的 this 指针传递给它。
class TalkingClock { ... public void start() { ActionListener listener = new TimePrinter(this); Timer t = new Timer(interval , listener); t.start(); } } class TimePrinter implements ActionListener { private TalkingClock outer; ... public TimePrinter(TalkingClock clock) { outer = clock; } }
现在, 看一下 actionPerformed方法,它需要访问 outer.beep。
if (outer.beep) . . . // Error
这就遇到了一个问题。内部类可以访问外围类的私有数据, 但这里的 TimePrinter 类则不行。
可见,由于内部类拥有访问特权, 所以与常规类比较起来功能更加强大。
可能有人会好奇,既然内部类可以被翻译成名字很古怪的常规类(而虚拟机对此一点 也不了解,) 内部类如何管理那些额外的访问特权呢? 为了揭开这个谜团,让我们再次利用 ReflectTest 程序査看一下 TalkingClock 类:
class TalkingClock { private int interval; private boolean beep; public TalkingClock(int, boolean); static boolean access$0(TalkingClock); public void start(); }
请注意编译器在外围类添加静态方法 access$0。它将返回作为参数传递给它的对象域 beep。(方法名可能稍有不同,如 access$000, 这取决于你的编译器。)
内部类方法将调用那个方法。在 TimePrinter 类的 actionPerformed 方法中编写语句:
if (beep)
将会提高下列调用的效率:
if (TalkingClock.access$0(outer))
这样做不是存在安全风险吗? 这种担心是很有道理的。任何人都可以通过调用 access$0 方法很容易地读取到私有域 beep。当然, access$0 不是 Java 的合法方法名。但熟悉类文件结构的黑客可以使用十六进制编辑器轻松地创建一个用虚拟机指令调用那个方法的类文件。由于隐秘地访问方法需要拥有包可见性,所以攻击代码需要与被攻击类放在同一个包中。
总而言之,如果内部类访问了私有数据域, 就有可能通过附加在外围类所在包中的其他类访问它们,但做这些事情需要高超的技巧和极大的决心。程序员不可能无意之中就获得对 类的访问权限,而必须刻意地构建或修改类文件才有可能达到这个目的。
注释: 合成构造器和方法是复杂令人费解的 (如果过于注重细节, 可以跳过这个注释)。 假设将 TimePrinter 转换为一个内部类。在虚拟机中不存在私有类, 因此编译器将会利用 私有构造器生成一个包可见的类:
private TalkingClock$TimePrinter(TalkingClock):
当然,没有人可以调用这个构造器,因此,存在第二个包可见构造器
TalkingClock$TimePrinter(TalkingClock, TalkingClock$l):
它将调用第一个构造器。 编译器将 TalkingClock 类 start 方法中的构造器调用翻译为:
new TalkingClock$TimePrinter(this, null)
6.4.4 局部内部类
如果仔细地阅读一下 TalkingClock 示例的代码就会发现, TimePrinter 这个类名字只在 start 方法中创建这个类型的对象时使用了一次。
当遇到这类情况时, 可以在一个方法中定义局部类。
public void start() { class TiiePrinter inpleients ActionListener { public void actionPerforaed(ActionEvent event) { System.out.println("At the tone, the tine is " + new Date()); if (beep) Toolkit.getDefaultToolkit()?beep(): } } ActionListener listener = new TimePrinter(); Timer t = new Timer(interva1, listener); t.start(); }
局部类不能用 public 或 private 访问说明符进行声明。它的作用域被限定在声明这个局部类的块中。
局部类有一个优势, 即对外部世界可以完全地隐藏起来。 即使 TalkingClock 类中的其他代码也不能访问它。除 start 方法之外, 没有任何方法知道 TimePrinter 类的存在。
6.4.5 由外部方法访问变量
与其他内部类相比较,局部类还有一个优点。它们不仅能够访问包含它们的外部类, 还可以访问局部变量。不过,那些局部变量必须事实上为 final。这说明, 它们一旦赋值就绝不会改变。
下面是一个典型的示例。这里, 将 TalkingClock 构造器的参数 interval 和 beep 移至 start 方法中。
public void start(int interval, boolean beep) { class TimePrinter implements ActionListener { public void actionPerformed(ActionEvent event) { Systea.out.println("At the tone, the time is " + new Date()); if (beep) Toolkit.getDefaultToolkit().beep(); } } ActionListener listener = new TimePrinter(); Timer t = new Timer(interval, listener); t.start(); }
请注意,TalkingClock 类不再需要存储实例变量 beep 了,它只是引用 start 方法中的 beep 参数变量。
这看起来好像没什么值得大惊小怪的。程序运行
if (beep) . . .
毕竟在 start 方法内部,为什么不能访问 beep 变量的值呢?
为了能够清楚地看到内部的问题,让我们仔细地考査一下控制流程。
1 ) 调用 start 方法。
2 ) 调用内部类 TimePrinter 的构造器, 以便初始化对象变量 listener。
3 ) 将 listener 引用传递给 Timer 构造器,定时器开始计时, start 方法结束。此时,start 方法的 beep 参数变量不复存在。
4 ) 然后,actionPerformed方法执行 if(beep)...
为了能够让 actionPerformed方法工作,TimePrinter 类在 beep 域释放之前将 beep 域用 start 方法的局部变量进行备份。实际上也是这样做的。在我们列举的例子中,编译器为局 部内部类构造了名字 TalkingClock$TimePrinter。 如果再次运行 ReflectionTest 程序, 查看 TalkingClock$Time- Printer 类,就会看到下列结果:
class TalkingClock$1TimePrinter { TalkingClock$1TinePrinter(TalkingClock, boolean); public void actionPerformed(java.awt.event.ActionEvent); final boolean val$beep; final TalkingClock this$0; }
请注意构造器的 boolean 参数和 val$beep 实例变量。当创建一个对象的时候, beep 就会 被传递给构造器,并存储在 val$beep 域中。编译器必须检测对局部变量的访问, 为每一个变量建立相应的数据域, 并将局部变量拷贝到构造器中, 以便将这些数据域初始化为局部变量的副本。
从程序员的角度看, 局部变量的访问非常容易。它减少了需要显式编写的实例域, 从而使得内部类更加简单。
前面曾经提到,局部类的方法只可以引用定义为 final 的局部变量。鉴于此情况,在列举 的示例中, 将 beep 参数声明为 final, 对它进行初始化后不能够再进行修改。因此,就使得局部变量与在局部类内建立的拷贝保持一致。
注释:在 JavaSE 8 之前, 必须把从局部类访问的局部变量声明为 final。 例如, start 方法 原本就应当这样声明, 从而使内部类能够访问 beep 参数:
public void start(int interval , final boolean beep)
有时,final 限制显得并不太方便。例如,假设想更新在一个封闭作用域内的计数器。这 里想要统计一下在排序过程中调用 compareTo 方法的次数。
int counter = 0; Date[] dates = new Date[100]; for (int i = 0; i < dates.length; i ++) dates[i] = new Date() { public int compareTo(Date other) { counter++; // Error return super.conpareTo(other); } }; Arrays.sort(dates); System.out.println(counter + " comparisons.");
由于清楚地知道 counter 需要更新, 所以不能将 counter 声明为 final。 由于 Integer 对象 是不可变的, 所以也不能用 Integer 代替它。补救的方法是使用一个长度为 1 的数组:
int[] counter = new int[1]; for (int i = 0; i < dates.length; i ++) dates[i] = new Date() { public int compareTo(Date other) { counter[0]++; return super.conpareTo(other); } };
在内部类被首次提出时,原型编译器对内部类中修改的局部变量自动地进行转换。不 过, 后来这种做法被废弃。毕竟, 这里存在一个危险。同时在多个线程中执行内部类中的代 码时, 这种并发更新会导致竞态条件—有关内容参见第 14 章。
6.4.6 匿名内部类
将局部内部类的使用再深入一步。 假如只创建这个类的一个对象,就不必命名了。这种类被称为匿名内部类(anonymous inner class)。
public void start(int interval, boolean beep) { ActionListener listener = new ActionListener() { public void actionPerformed(ActionEvent event) { System.out.println("At the tone, the time is " + new Date()); if (beep) Toolkit.getDefaultToolkit().beep(); } }; Timer t = new Timer(interval, listener); t.start(); }
这种语法确实有些难以理解。它的含义是:创建一个实现 ActionListener 接口的类的新对象,需要实现的方法 actionPerformed 定义在括号内。
通常的语法格式为:
new SuperType(construction parameters) { inner class methods and data }
其中, SuperType 可以是 ActionListener 这样的接口, 于是内部类就要实现这个接口。 SuperType 也可以是一个类,于是内部类就要扩展它。
由于构造器的名字必须与类名相同, 而匿名类没有类名,所以,匿名类不能有构造器。 取而代之的是,将构造器参数传递给超类( superclass) 构造器。尤其是在内部类实现接口的 时候, 不能有任何构造参数。不仅如此,还要像下面这样提供一组括号:
new InterfaceType() { methods and data }
请仔细研究一下,看看构造一个类的新对象与构造一个扩展了那个类的匿名内部类的对 象之间有什么差别。
Person queen = new Person("zhangsan"); // a Person object Person count = new Person("lisi") {...}; // an object of an inner class extending Person
如果构造参数的闭小括号后面跟一个开大括号, 正在定义的就是匿名内部类。
程序清单 6-8包含了用匿名内部类实现语音时钟程序的全部源代码。将这个程序与程序 清单 6-7 相比较就会发现使用匿名内部类的解决方案比较简短、 更切实际、 更易于理解。
多年来,Java 程序员习惯的做法是用匿名内部类实现事件监听器和其他回调。如今最好还是使用 lambda 表达式。例如, 这一节前面给出的 start 方法用 lambda 表达式来写会简洁得多, 如下所示:
public void start(int interval, boolean beep) { Timer t = new Timer(interval, event -> { Systea.out.println("At the tone, the time is " + new Date()); if (beep) Toolkit.getDefaultToolkit().beep(); }); t.start(); }
注释: 下面的技巧称为“ 双括号初始化” (double brace initialization), 这里利用了内部类 语法。假设你想构造一个数组列表,并将它传递到一个方法:
ArrayListfriends = new ArrayList<>(); friends.add("Harry"); friends.add("Tony"); invite(friends);
如果不再需要这个数组列表,最好让它作为一个匿名列表。不过作为一个匿名列表, 该如何为它添加元素呢? 方法如下:
invite(new ArrayList() {{ add("Harry"); add("Tony"); }});
注意这里的双括号。外层括号建立了 ArrayList 的一个匿名子类。内层括号则是一个对象构造块(见第 4 章)。
警告: 建立一个与超类大体类似(但不完全相同)的匿名子类通常会很方便。不过,对 于 equals 方法要特别当心。第 5 章中, 我们曾建议 equals 方法最好使用以下测试:
if (getClass() != other.getClass()) return false;
但是对匿名子类做这个测试时会失败。
提示:生成曰志或调试消息时, 通常希望包含当前类的类名, 如:
Systen.err.println("Something awful happened in " + getClass());
不过,这对于静态方法不奏效。毕竟, 调用 getClass 时调用的是 this.getClass(), 而 静态方法没有 this。所以应该使用以下表达式:
new Object(){}.getCIass().getEnclosingClass() // gets class of static method
在这里,newObject0{} 会建立 Object 的一个匿名子类的一个匿名对象,getEnclosingClass 则得到其外围类, 也就是包含这个静态方法的类。
6.4.7 静态内部类
有时候, 使用内部类只是为了把一个类隐藏在另外一个类的内部,并不需要内部类引用 外围类对象。为此,可以将内部类声明为 static, 以便取消产生的引用。
下面是一个使用静态内部类的典型例子。考虑一下计算数组中最小值和最大值的问题。 当然, 可以编写两个方法, 一个方法用于计算最小值,另一个方法用于计算最大值。在调用 这两个方法的时候,数组被遍历两次。如果只遍历数组一次, 并能够同时计算出最小值和最 大值,那么就可以大大地提高效率了。
double min = Double.POSITIVE_INFINITY; double max = Double.NECATIVE_INFINITY; for (double v : values) { if (min > v) min = v; if (max < v) max = v; }
然而, 这个方法必须返冋两个数值, 为此, 可以定义一个包含两个值的类 Pair:
class Pair { private double first; private double second; public Pair(double f, double s) { first = f; second = s; } public double getFirst() { return first; } public double getSecond() { return second; } }
minmax 方法可以返回一个 Pair 类型的对象。
class ArrayAlg { public static Pair minmax(doublet[] values) { return new Pair(min, max) ; } }
这个方法的调用者可以使用 getFirst 和 getSecond 方法获得答案:
Pair p = ArrayAlg.minmax(d); System,out.println("min = " + p.getFirst()); System,out.println("max = " + p.getSecond());
当然, Pair 是一个十分大众化的名字。在大型项目中, 除了定义包含一对字符串的 Pair 类之外, 其他程序员也很可能使用这个名字。这样就会产生名字冲突。解决这个问题的办法 是将 Pair 定义为 ArrayAlg 的内部公有类。此后, 通过 ArrayAlg.Pair 访问它:
ArrayAlg.Pair p = ArrayAlg.minmax(d);
不过, 与前面例子中所使用的内部类不同, 在 Pair 对象中不需要引用任何其他的对象, 为此, 可以将这个内部类声明为 static:
class ArrayAlg { public static class Pair { ... } ... }
当然, 只有内部类可以声明为 static。静态内部类的对象除了没有对生成它的外围类对象 的引用特权外, 与其他所有内部类完全一样。在我们列举的示例中, 必须使用静态内部类, 这是由于内部类对象是在静态方法中构造的:
public static Pair minmax(double[] d) { ... return new Pair(min, max); }
如果没有将 Pair 类声明为 static, 那么编译器将会给出错误报告: 没有可用的隐式 ArrayAIg 类型对象初始化内部类对象。
注释: 在内部类不需要访问外围类对象的时候, 应该使用静态内部类。 有些程序员用嵌 套类 (nested class) 表示静态内部类。
注释: 与常规内部类不同,静态内部类可以有静态域和方法。
注释: 声明在接口中的内部类自动成为 static 和 public 类。
6.5 代理
在本章的最后, 讨论一下代理 ( proxy) 。利用代理可以在运行时创建一个实现了一组给定接口的新类 。这种功能只有在编译时无法确定需要实现哪个接口时才有必要使用。对于应用程序设计人员来说, 遇到这种情况的机会很少。如果对这种高级技术不感兴趣,可以跳过 本节内容。然而,对于系统程序设计人员来说,代理带来的灵活性却十分重要。
6.5.1 何时使用代理
假设有一个表示接口的 Class 对象(有可能只包含一个接口) ,它的确切类型在编译时无法知道。这确实有些难度。要想构造一个实现这些接口的类,就需要使用 newlnstance 方法 或 反射找出这个类的构造器。但是, 不能实例化一个接口,需要在程序处于运行状态时定义 一个新类。
为了解决这个问题, 有些程序将会生成代码;将这些代码放置在一个文件中;调用编译器;然后再加载结果类文件。很自然, 这样做的速度会比较慢,并且需要将编译器与程序放 在一起。而代理机制则是一种更好的解决方案。代理类可以在运行时创建全新的类。这样的代理类能够实现指定的接口。尤其是,它具有下列方法:
? 指定接口所需要的全部方法。
? Object 类中的全部方法, 例如, toString、 equals 等。
然而,不能在运行时定义这些方法的新代码。而是要提供一个调用处理器( invocation handler)。调用处理器是实现了 InvocationHandler 接口的类对象。在这个接口中只有一个方法:
Object invoke(Object proxy, Method method, Object[] args)
无论何时调用代理对象的方法,调用处理器的 invoke 方法都会被调用, 并向其传递 Method 对象和原始的调用参数。 调用处理器必须给出处理调用的方式。
6.5.2 创建代理对象
要想创建一个代理对象, 需要使用 Proxy 类的 newProxylnstance 方法。 这个方法有三个 参数:
? 一个类加载器(class loader) 。作为 Java 安全模型的一部分, 对于系统类和从因特网 上下载下来的类,可以使用不同的类加载器。有关类加载器的详细内容将在卷2 第 9 章中讨论。目前, 用 null 表示使用默认的类加载器。
? 一个 Class 对象数组, 每个元素都是需要实现的接口。
? 一个调用处理器。
还有两个需要解决的问题。如何定义一个处理器? 能够用结果代理对象做些什么? 当 然, 这两个问题的答案取决于打算使用代理机制解决什么问题。使用代理可能出于很多原 因,例如:
? 路由对远程服务器的方法调用。
? 在程序运行期间,将用户接口事件与动作关联起来。
? 为调试, 跟踪方法调用。
在示例程序中,使用代理和调用处理器跟踪方法调用,并且定义了一个 TraceHander 包装器类存储包装的对象。其中的 invoke 方法打印出被调用方法的名字和参数,随后用包装好的对象作为隐式参数调用这个方法。
class TraceHandler implements InvocationHandler { private Object target; public TraceHandler(Object t) { target = t; } public Object invoke (Object proxy, Method m, Object[] args) throws Throwable { // print method name and parameters // invoke actual method return m.invoke(target , args); } }
下面说明如何构造用于跟踪方法调用的代理对象。
Object value =... // construct wrapper InvocationHandler handler = new TraceHandler(value) ; // construct proxy for one or more interfaces Class[] interfaces = new Class[]{ Comparable.class}; Object proxy = Proxy.newProxylnstance(null , interfaces, handler);
现在,无论何时用 proxy 调用某个方法, 这个方法的名字和参数就会打印出来, 之后再 用 value 调用它。
在程序清单 6-10 给出的程序中, 使用代理对象对二分查找进行跟踪。这里, 首先将用 1 ~ 1000 整数的代理填充数组,然后调用 Arrays 类中的 binarySearch 方法在数组中查找一个 随机整数。最后, 打印出与之匹配的元素。
Object[] elements = new Object[1000]: // fill elements with proxies for the integers 1 . . . 1000 for (int i = 0; i < elements.length; i ++) { Integer value = i + 1; elements[i] = Proxy.newProxylnstance(. . .); // proxy for value; } // construct a random integer Integer key = new Random().nextlnt(elements.length) + 1; // search for the key int result = Arrays.binarySearch(elements, key) ; // print match if found if (result >= 0) System.out.println(elements[result]);
在上述代码中,Integer 类实现了 Comparable 接口。代理对象属于在运行时定义的类(它 有一个名字, 如 $Proxy0 ) 。这 个 类 也 实 现 了 Comparable 接口。然而,它的 compareTo 方法调用了代理对象处理器的 invoke 方法。
注释: 前面已经讲过, 在 Java SE 5.0 中, Integer 类实际上实现了 Comparable 。然 而, 在运行时, 所有的泛型类都被取消, 代理将它们构造为原 Comparable 类的类对象。
binarySearch 方法按下面这种方式调用:
if (elements[i].compareTo(key) < 0) . . .
由于数组中填充了代理对象, 所以 compareTo 调用了 TraceHander 类中的 invoke 方法。 这个方法打印出了方法名和参数, 之后用包装好的 Integer 对象调用 compareTo。
最后, 在示例程序的结尾调用:
System.out.println(elements[result]);
println 方法调用代理对象的 toString, 这个调用也会被重定向到调用处理器上。
下面是程序运行的全部跟踪结果:
500.compareTo(288) 250.compareTo(288) 375.compareTo(288) 312.compareTo(288) 281.compareTo(288) 296.compareTo(288) 288.compareTo(288) 288.toString()
可以看出,二分查找算法查找关键字的过程, 即每一步都将查找区间缩减一半。注意, 即使不属于 Comparable 接口, toString 方法也被代理。在下一节中会看到, 有相当一部分的 Object 方法都被代理。
6.5.3 代理类的特性
现在,我们已经看到了代理类的应用, 接下来了解它们的一些特性。 需要记住, 代理类是在程序运行过程中创建的。然而, 一旦被创建, 就变成了常规类,与虚拟机中的任何其他 类没有什么区别。
所有的代理类都扩展于 Proxy 类。一个代理类只有一个实例域---调用处理器,它定义 在 Proxy 的超类中。 为了履行代理对象的职责,所需要的任何附加数据都必须存储在调用处理器中。 例如, 在程序清单 6-10 给出的程序中,代理 Comparable 对象时,TraceHandler 包装了实际的对象。
所有的代理类都覆盖了 Object 类中的方法 toString、 equals 和 hashCode。如同所有的代 理方法一样,这些方法仅仅调用了调用处理器的 invoke。Object 类中的其他方法(如 clone 和 getClass) 没有被重新定义。
没有定义代理类的名字,Sun 虚拟机中的 Proxy类将生成一个以字符串 $Proxy 开头的类名。
对于特定的类加载器和预设的一组接口来说,只能有一个代理类。 也就是说,如果使用 同一个类加载器和接口数组调用两次 newProxylnstance方法的话, 那么只能够得到同一个类 的两个对象,也可以利用 getProxyClass方法获得这个类:
Class proxyClass = Proxy.getProxyClass(null, interfaces);
代理类一定是 public 和 final。如果代理类实现的所有接口都是 public, 代理类就不属于 某个特定的包;否则, 所有非公有的接口都必须属于同一个包,同时,代理类也属于这个包。
可以通过调用 Proxy 类中的 isProxyClass 方法检测一个特定的 Class 对象是否代表一个代理类。
API java.Iang.reflect.InvocationHandler 1.3
?Object invoke(Object proxy,Method method,0bject[]args)
定义了代理对象调用方法时希望执行的动作。
API java.Iang.reflect.Proxy 1.3
? static Class getProxyClass(ClassLoader loader, Class... interfaces)
返回实现指定接口的代理类。
? static Object newProxyInstance(ClassLoader loader, Class[] interfaces, InvocationHandler handler)
构造实现指定接口的代理类的一个新实例。 所有方法会调用给定处理器对象的 invoke 方法。
? static boolean isProxyClass(Class cl)
如果 cl 是一个代理类则返回 true。
到此为止,Java 程序设计语言的基础概念介绍完毕了。接口、lambda 表达式和内部类是 经常使用的几个概念。然而,前面已经提到过,克隆和代理是库设计者和工具构造者感兴趣 的高级技术, 对应用程序员来说,它们并不十分重要。 下面准备在第 7 章学习如何处理程序中的异常情况。