《Java核心技术卷一》第六章

第6章 接口、lambda 表达式与内部类

首先介绍第一种技术,即接口(interface)接口用来描述类应该做什么,而不指定它们具体应该如何做。一个类可以实现(implement)一个或多个接口。只要符合所要求的接口,就可以使用实现了这个接口的类(即实现类)的对象。讨论接口以后,我们会继续介绍 lambda 表达式,这是一种简洁的方法,用来创建可以在将来某个时间点执行的代码块。通过使用 lambda 表达式,可以用一种精巧而简洁的方式表示使用回调或可变行为的代码。

接下来,我们将讨论内部类(inner class)机制。理论上讲,内部类有些复杂,内部类定义在另外一个类的内部,它们的方法可以访问其外部类的字段。内部类技术在设计合作类集合时很有用。

6.1 接口

在下面的小节中,你会了解 Java 接口是什么以及如何使用,另外还会了解 Java 最新的儿个版本中接口的功能有怎样的提升。

6.1.1 接口的概念

在 Java 程序设计语言中,接口不是类,而是对希望符合这个接口的类的一组需求

我们经常听到某个服务的提供商这样说:“如果你的类符合某个特定接口,我就会履行这项服务。”下面给出一个具体的示例。Arrays类中的sort方法承诺可以对对象数组进行排序,但要求满足下面这个条件:对象所属的类必须实现Comparable接口。

下面是Comparable接口的代码:

1
2
3
public interface Comparable {
int compareTo(Object other);
}

在这个接口中,compareTo 方法是抽象的,它没有具体实现。任何实现 Comparable 接口的类都需要包含一个 compareTo 方法,这个方法必须接受一个 Object 参数,并返回一个整数。否则,这个类也应当是抽象的,也就是说,你不能构造这个类的对象。

注释:在 Java 5 中,Comparable 接口已经提升为一个泛型类型。

例如,在实现 Comparable<Employee> 接口的类中,必须提供以下方法:

1
2
3
public interface Comparable<T> {
int compareTo(T other); // parameter has type T
}

仍然可以使用不带类型参数的 “ 原始 ” Comparable 类型。这样一来,compareTo 方法就有一个 Object 类型的参数,你必须手动将 compareTo 方法的这个参数强制转换为所希望的类型。稍后我们再做这个工作,所以不用担心同时学习两个新概念。

接口中的所有方法都自动是 public 方法。因此,在接口中声明方法时,不必提供关键字 public 。

当然,还有一个接口没有明确说明的额外要求:调用 x.compareTo(y) 的时候,这个 compareTo 方法实际上必须能够比较两个对象,并返回比较的结果,即 x 和 y 哪一个更大。当 x 小于 y 时,返回一个负数;当 x 等于 y 时,返回 0 ;否则返回一个正数。

这个特定接口只有一个方法,而有些接口可能包含多个方法。稍后可以看到,接口还可以定义常量。不过,更重要的是要知道接口不能提供什么。接口绝不会有实例字段,在 Java 8 之前,接口中的方法都是抽象方法。(在6.1.4节和6.1.5节中可以看到,现在接口中还可以有其他方法。当然,那些方法不能引用实例字段——接口没有实例。)

现在,假设希望使用 Arrays 类的 sort 方法对 Employee 对象数组进行排序,Employee 类就必须实现 Comparable 接口。

为了让类实现一个接口,需要完成下面两个步骤:

  1. 将类声明为实现给定的接口。
  2. 对接口中的所有方法提供定义。

要声明一个类实现某个接口,需要使用关键字 implements:

1
class Employee implements Comparable

当然,现在 Employee 类需要提供 compareTo 方法。假设我们希望根据员工的薪水进行比较。以下是 compareTo 方法的一个实现:

1
2
3
4
public int compareTo(Object otherObject) {
Employee other = (Employee) otherObject;
return Double.compare(salary, other.salary);
}

在这里,我们使用了静态 Double.compare 方法。如果第一个参数小于第二个参数,它会返回一个负值;如果二者相等则返回 0;否则返回一个正值。

⚠️ 警告:在接口声明中,没有将 compareTo 方法声明为 public ,这是因为,接口中的所有方法都自动是 public 方法。不过,在实现接口时,必须把方法声明为 public,否则,编译器将认为这个方法的访问属性是包可访问,这是类中默认的访问属性,之后编译器就会报错,指出你试图提供更严格的访问权限。

我们可以做得更好一些。可以为泛型 Comparable 接口提供一个类型参数:

1
2
3
4
5
class Employee implements Comparable<Employee> {
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。

注释:Comparable 接口的文档建议 compareTo 方法应当与 equals 方法兼容。也就是说,当 x.equals(y) 时,x.compareTo(y) 就应当等于0。Java API 中大多数实现 Comparable 接口的类都遵从了这个建议。不过有一个重要的例外,就是 BigDecimal 。考虑 x = new BigDecimal("1.0")y = new BigDecimal("1.00")。这里 x.equals(y) 为false,因为两个数的精度不同。不过 x.compareTo(y) 为0。理想情况下应该不返回0,但是没有明确的方法能够确定这两个数哪一个更大。

现在,我们已经看到,要让一个类使用排序服务必须让它实现 compareTo 方法。这是理所当然的,因为要向 sort 方法提供对象的比较方式。但是为什么不能在 Employee 类中直接提供一个 compareTo 方法(而不实现Comparable接口)呢?

使用接口的主要原因在于:Java程序设计语言是一种强类型(strongly typed)语言。调用方法时,编译器要能检查这个方法确实存在。在 sort 方法中可能会有类似下面的语句:

1
2
3
4
5
if (a[i].compareTo(a[j]) > 0)
{
//rearrange a[i] and a[j]
. . .
}

编译器必须确认 a[i] 一定有一个 compareTo 方法。如果 a 是一个 Comparable 对象的数组,就可以确保肯定有 compareTo 方法,因为每个实现 Comparable 接口的类都必须提供这个方法。

注释:你可能认为,如果将 Arrays 类中的 sort 方法定义为接受一个 Comparable 数组,倘若有人调用 sort 方法时所提供数组的元素类型没有实现 Comparable 接口,编译器就能报错。遗憾的是,事实并非如此。实际上,sort 方法接受一个 Object 数组,并使用一个笨拙的强制类型转换:

1
2
3
4
5
6
 // 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 接口的类,虚拟机就会抛出一个异常。

注释:语言标准规定:“对于任意的 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<Employee>,而不是 Comparable<Manager>。如果 Manager 要盖 compareTo,就必须做好准备比较经理与员工,绝不能简单地将员工强制转换成经理:

1
2
3
4
5
6
7
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 方法首先都应该进行以下检测:

1
if (getClass() != other.getClass()) throw new ClassCastException();

如果存在一个比较子类对象的通用算法,那么只需要在超类中提供一个 compareTo 方法,并将这个方法声明为final。

例如,假设你希望经理大于普通员工,而不论薪水多少,那么诸如 Executive 和 Secretary 等其他子类呢?如果要按照职位排序,那就应该在 Employee 类中提供一个rank方法。让每个子类覆盖 rank,并实现一个考虑 rank值的 compareTo 方法。

6.1.2 接口的属性

**接口不是类。**具体来说,不能使用 new 操作符实例化一个接口:

1
x = new Comparable(...); // ERROR

尽管不能构造接口对象,但仍然能声明接口变量

1
Comparable x; // OK

接口变量必须引用实现了这个接口的一个类对象

1
x = new Employee(...); // OK provided Employee implements Comparable

如同使用 instanceof 检查一个对象是否属于某个特定类一样,也可以使用 instanceof 检查一个对象是否实现了某个特定的接口

1
if (anObject instanceof Comparable) { ... }

与建立类的继承层次结构一样,也可以扩展接口。这允许有多条接口链,从通用性较高的接口扩展到专用性较高的接口。例如,假设有一个名为 Moveable 的接口:

1
2
3
public interface Moveable {
void move(double x, double y);
}

然后,可以假设一个名为 Powered 的接口扩展了以上 Moveable 接口:

1
2
3
public interface Powered extends Moveable {
double milesPerGallon();
}

虽然接口中不能包含实例字段,但是可以包含常量。例如:

1
2
3
4
public interface Powered extends Moveable {
double milesPerGallon();
double SPEED_LIMIT = 95; // a public static final constant
}

接口中的方法都自动为 public,类似地,接口中的字段总是 public static final

**注释:**可以将接口方法显式标记为 public,将字段标记为 public static final,这是合法的。有些程序员出于习惯或者提高清晰度的考虑,可能会这样做。但 Java 语言规范建议不要提供冗余的关键字,本书也采纳了这个建议。

尽管每个类只能有一个超类,但可以实现多个接口。这就为定义类的行为提供了极大的灵活性。例如,Java 程序设计语言有一个非常重要的内置接口,名为 Cloneable。如果你的类实现了这个 Cloneable 接口,Object 类中的 clone 方法就可以创建你的类对象的一个完全副本。如果你希望自己设计的类既能够克隆又能够比较,只要实现这两个接口就可以了。可以使用逗号将想要实现的各个接口分隔开。

1
class Employee implements Cloneable, Comparable

注释记录和枚举类不能扩展其他类(因为它们隐式地扩展了 RecordEnum 类)。不过,它们可以实现接口。

注释接口可以是密封的sealed)。与密封类一样,直接子类型(可以是类或接口)必须在 permits 子句中声明,或者要放在同一个源文件中。

6.1.3 接口与抽象类

如果阅读了第5章中有关抽象类的那一节,可能会产生这样一个疑问:为什么 Java 程序设计语言的设计者要那么麻烦地引入接口概念呢?为什么不将 Comparable 直接设计成一个抽象类呢?如下所示:

1
2
3
abstract class Comparable { // why not?
public abstract int compareTo(Object other);
}

这样一来,Employee 类只需要扩展这个抽象类,并提供 compareTo 方法:

1
2
3
class Employee extends Comparable { // why not?
public int compareTo(Object other) { ... }
}

非常遗憾,使用抽象基类表示通用属性存在一个严重的问题。每个类只能扩展一个类。假设 Employee 类已经扩展了另一个类,例如 Person,它就不能再扩展第二个类了:

1
class Employee extends Person, Comparable // ERROR

但每个类可以实现任意多个接口,如下所示:

1
class Employee extends Person implements Comparable // OK

其他程序设计语言(尤其是 C++)允许一个类有多个超类。这个特性称为多重继承(multiple inheritance)。Java 的设计者选择不支持多重继承,其主要原因是多重继承会让语言变得非常复杂(如 C++),或者效率会降低(如 Eiffel)。

实际上,接口可以提供多重继承的大多数好处,同时还能避免多重继承的复杂性和低效性。

6.1.4 静态和私有方法

在 Java 8 中,允许在接口中增加静态方法。理论上讲,没有任何理由认为这是不合法的。只是这似乎有违于将接口作为抽象规范的初衷。

目前为止,通常的做法都是将静态方法放在伴随类中。在标准库中,你会看到成对出现的接口和实用工具类,如 Collection/CollectionsPath/Paths

可以由一个 URI 或者字符串序列构造一个文件或目录的路径,如:

1
Paths.get("jdk-17", "conf", "security");

在 Java 11 中,Path 接口提供了等价的方法:

1
2
3
4
public interface Path {
public static Path of(URI uri) { ... }
public static Path of(String first, String... more) { ... }
}

这样一来,Paths 类就不再是必要的了。

类似地,实现你自己的接口时,没有理由再为实用工具方法另外提供一个伴随类。

在 Java 9 中,接口中的方法可以是 private 方法。private 方法可以是静态方法或实例方法。由于私有方法只能在接口本身的方法中使用,所以它们的用途很有限,只是作为接口中其他方法的辅助方法。

6.1.5 默认方法

可以为任何接口方法提供一个默认实现。必须用 default 修饰符标记这样一个方法。

1
2
3
public interface Comparable<T> {
default int compareTo(T other) { return 0; } // by default, all elements are the same
}

当然,这并没有太大用处,因为 Comparable 的每一个具体实现都会覆盖这个方法。不过有些情况下,默认方法可能很有用。例如,在第9章会看到一个 Iterator 接口,用于访问一个数据结构中的元素。这个接口声明了一个 remove 方法,如下所示:

1
2
3
4
5
6
public interface Iterator<E> {
boolean hasNext();
E next();
default void remove() { throw new UnsupportedOperationException("remove"); }
...
}

如果你要实现一个迭代器,就需要提供 hasNextnext 方法。这些方法没有默认实现——它们依赖于你要遍历访问的数据结构。不过,如果你的迭代器是只读的,就不用操心实现 remove 方法。

默认方法可以调用其他方法。例如,Collection 接口可以定义一个便利方法:

1
2
3
4
public interface Collection {
int size(); // an abstract method
default boolean isEmpty() { return size() == 0; }
}

这样实现 Collection 的程序员就不用再操心实现 isEmpty 方法了。

注释:Java API 中的 Collection 接口并没有这样做。实际上,有一个 AbstractCollection 类实现了 Collection,并利用 size 定义了 isEmpty。建议实现集合的程序员扩展 AbstractCollection。不过那个技术已经过时,现在可以直接在接口中实现方法。

默认方法的一个重要用法是 “ 接口演化 ”(interface evolution)。以 Collection 接口为例,这个接口作为 Java 的一部分已经有很多年了。假设很久以前你提供了这样一个类:

1
2
3
public class Bag implements Collection {
...
}

后来,在 Java 8 中,又为这个接口增加了一个 stream 方法。

假设 stream 方法不是一个默认方法,那么 Bag 类将不能编译,因为它没有实现这个新方法。为接口增加一个非默认方法不能保证 “ 源代码兼容 ”(source compatible)

不过,假设不重新编译这个类,而只是使用原先的一个包含这个类的 JAR 文件。这个类仍能正常加载,尽管没有这个新方法。程序仍然可以正常构造 Bag 实例,不会有意外发生。(为接口增加方法可以做到“二进制兼容”。)不过,如果一个程序在一个 Bag 实例上调用 stream 方法,就会出现一个 AbstractMethodError

将方法实现为一个默认(default)方法就可以解决这两个问题。Bag 类又能正常编译了。另外如果没有重新编译而直接加载这个类,并在一个 Bag 实例上调用 stream 方法,则会调用 Collection.stream 方法。

6.1.6 解决默认方法冲突

如果先在一个接口中将一个方法定义为默认方法,然后又在超类或另一个接口中定义了同样的方法,会发生什么情况?诸如 Scala 和 C++ 等语言对于解决这种二义性有一些复杂的规则。幸运的是,Java 的相应规则要简单得多。规则如下:

  1. 超类优先。如果超类提供了一个具体方法,同名而且有相同参数类型的默认方法会被忽略。

  2. 接口冲突。如果一个接口提供了一个默认方法,另一个接口提供了一个同名而且参数类型相同的方法(不论是否是默认方法),必须覆盖这个方法来解决冲突。

下面来看第二个规则。考虑两个包含 getName 方法的接口:

1
2
3
4
5
6
7
interface Person {
default String getName() { return ""; }
}

interface Named {
default String getName() { return getClass().getName() + "_" + hashCode(); }
}

如果有一个类同时实现了这两个接口会怎么样呢?

1
class Student implements Person, Named { ... }

这个类会继承 PersonNamed 接口提供的两个不一致的 getName 方法。并不是从中选择一个,Java 编译器会报告一个错误,让程序员来解决这个二义性问题。只需要在 Student 类中提供一个 getName 方法即可。在这个方法中,可以选择两个冲突方法中的一个,如下所示:

1
2
3
class Student implements Person, Named {
public String getName() { return Person.super.getName(); }
}

现在假设 Named 接口没有为 getName 提供默认实现:

1
2
3
interface Named {
String getName();
}

Student 类会从 Person 接口继承默认方法吗?这好像挺合理,不过,Java 设计者决定更强凋一致性。两个接口如何冲突并不重要。如果至少有一个接口提供了一个实现,编译器就会报告错误,必须由程序员解决这个二义性。

注释:当然,如果两个接口都没有为共享方法提供默认实现,那么就与 Java 8 之前的情况一样,这里不存在冲突。实现类可以有两个选择:实现这个方法,或者干脆不实现。如果是后一种情况,这个类本身就是抽象的。

我们只讨论了两个接口的命名冲突。现在来考虑另一种情况,一个类扩展了一个超类,同时实现了一个接口,并从超类和接口继承了相同的方法。例如,假设 Person 是一个类,Student 定义为:

1
class Student extends Person implements Named { ... }

在这种情况下,只会考虑超类方法,接口的所有默认方法都会被忽略。在我们的例子中,StudentPerson 继承了 getName 方法,Named 接口是否为 getName 提供了默认实现并不会带来什么区别。这正是 “ 类优先 ” 规则。

“ 类优先 ” 规则可以确保与 Java 7 的兼容性。如果为一个接口增加默认方法,这对于有默认方法之前能正常工作的代码不会有任何影响。

警告: 绝对不能创建一个默认方法重新定义 Object 类中的某个方法。例如,不能为 toStringequals 定义默认方法,尽管对于 List 之类的接口这可能很有吸引力。由于“类优先”规则,这样的方法绝对无法超越 Object.toStringObject.equals

6.1.7 接口与回调

**回调(callback)**是一种常见的程序设计模式。在这种模式中,可以指定某个特定事件发生时应该采取的动作。例如,点击一个按钮或选择某个菜单项时,你可能希望完成某个特定的动作。不过,由于目前还没有介绍如何实现用户界面,所以我们来考虑一种类似但更简单的情况。

javax.swing 包中有一个 Timer 类,如果希望经过一定时间间隔就得到通知,Timer 类就很有用。例如,假如程序中有一个时钟,你可以请求每秒通知一次,以便更新时钟的表盘。

构造定时器时,需要设置一个时间间隔,并告诉定时器经过这个时间间隔时要做些什么。

如何告诉定时器要做什么呢?在很多程序设计语言中,可以提供一个函数名,定时器要定期地调用这个函数。但是,Java 标准类库中的类采用了一种面向对象方法。你可以向定时器传入某个类的对象,然后,定时器调用这个对象的某个方法。由于对象可以携带额外的信息,所以传递一个对象比传递一个函数要灵活得多。

当然,定时器需要知道要调用哪一个方法。它要求你指定一个类的对象,这个类要实现 java.awt.event 包中的 ActionListener 接口。下面是这个接口:

1
2
3
public interface ActionListener {
void actionPerformed(ActionEvent event);
}

当达到指定的时间间隔时,定时器就调用 actionPerformed 方法。

假设你希望每秒打印一条消息 “At the tone, the time is”,然后响一声,那么可以定义一个实现 ActionListener 接口的类,然后将想要执行的语句放在 actionPerformed 方法中。

1
2
3
4
5
6
7
class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
Toolkit.getDefaultToolkit().beep();
}
}

需要注意 actionPerformed 方法的 ActionEvent 参数。这个参数提供了事件的相关信息,例如,发生这个事件的时间。event.getWhen() 调用会返回这个事件时间,表示为“纪元”(1970年 1 月 1 日)以来的毫秒数。如果把它传入静态方法 Instant.ofEpochMilli,可以得到一个更可读的描述。

接下来,构造这个类的一个对象,并将它传递到 Timer 构造器。

1
2
var listener = new TimePrinter();
Timer t = new Timer(1000, listener);

Timer 构造器的第一个参数是一个时间间隔(单位是毫秒),即经过多长时间通知一次。这里希望每秒通知一次。第二个参数是监听器对象。

最后,启动定时器:

1
t.start();

每过 1 秒就会显示下面的消息,然后响一声铃:

1
At the tone, the time is 2017-12-16T05:01:49.550Z

警告: 一定要导入 javax.swing.Timer。另外还有一个稍有区别的 java.util.Timer 类。

6.1.8 Comparator 接口

6.1.1 节中,我们已经了解了如何对一个对象数组进行排序,前提是这些对象是实现了 Comparable 接口的类的实例。例如,可以对一个字符串数组排序,因为 String 类实现了 Comparable<String>,而且 String.compareTo 方法可以按字典顺序比较字符串。

现在假设我们希望按长度递增的顺序对字符串进行排序,而不是按字典顺序进行排序。

肯定不能让 String 类用两种不同的方式实现 compareTo 方法——更何况,String 类也不应由我们来修改。

要处理这种情况,Arrays.sort 方法还有第二个版本,接受一个数组和一个比较器(comparator)作为参数,比较器是实现了 Comparator 接口的类的实例。

1
2
3
public interface Comparator<T> {
int compare(T first, T second);
}

要按长度比较字符串,可以如下定义一个实现 Comparator<String> 的类:

1
2
3
4
5
class LengthComparator implements Comparator<String> {
public int compare(String first, String second) {
return first.length() - second.length();
}
}

具体完成比较时,需要建立一个实例:

1
2
var comp = new LengthComparator();
if (comp.compare(words[i], words[j]) > 0) ...

将这个调用与 words[i].compareTo(words[j]) 做个比较。这个 compare 方法要在比较器对象上调用,而不是在字符串本身调用。

注释:尽管 LengthComparator 对象没有状态,不过还是需要创建一个对象实例。我们需要这个实例来调用 compare 方法——它不是一个静态方法。

要对一个数组排序,需要为 Arrays.sort 方法传入一个 LengthComparator 对象:

1
2
String[] friends = { "Peter", "Paul", "Mary" };
Arrays.sort(friends, new LengthComparator());

现在这个数组可能是 ["Paul", "Mary", "Peter"]["Mary", "Paul", "Peter"]

6.1.9 对象克隆

本节我们会讨论 Cloneable 接口,这个接口表示一个类提供了一个安全的 clone 方法。

要了解克隆的具体含义,先来回忆为一个包含对象引用的变量建立副本时会发生什么(这就是拷贝)。拷贝原变量和副本都是同一个对象的引用(见图 6-1)。这说明,任何一个变量的改变都会影响另一个变量。

image-20251112162316224

图 6-1 拷贝和克隆

1
2
3
var original = new Employee("John Public", 50000);
Employee copy = original;
copy.raiseSalary(10); // oops--also changed original

如果希望 copy 是一个新对象,它的初始状态与 original 相同,但是之后它们的状态可能不同,这种情况下就要使用 clone 方法(即克隆)。

1
2
Employee copy = original.clone();
copy.raiseSalary(10); // OK--original unchanged

不过并没有这么简单。clone 方法是 Object 的一个 protected 方法,这说明你的代码不能直接调用这个方法。只有 Employee 类可以克隆 Employee 对象。这个限制是有原因的。想想看 Object 类如何实现 clone。它对于这个对象一无所知,所以只能逐个字段地进行拷贝。如果对象中的所有实例字段都是数值或其他基本类型,拷贝这些字段没有任何问题。但是如果对象包含子对象的引用,拷贝字段就会得到相同子对象的另一个引用,这样一来,原对象和克隆的对象仍然会共享一些信息。

为了更直观地说明这个问题,考虑第 4 章介绍过的 Employee 类。图 6-2 显示了使用 Object 类的 clone 方法克隆这样一个 Employee 对象会发生什么。可以看到,默认的克隆操作是“浅拷贝”,并没有克隆对象中引用的其他对象。(这个图显示了一个共享的 Date 对象。出于某种原因(稍后就会解释这个原因),这个例子使用了 Employee 类的老版本,其中的雇佣日期仍用 Date 表示。)

image-20251114112834579

图 6-2 浅拷贝

浅拷贝会有什么影响吗?这要看具体情况。如果原对象和浅克隆对象共享的子对象是不可变的,那么这种共享就是安全的。如果子对象属于一个不可变的类,如 String,就是这种情况;或者,在对象的生命期中,子对象一直保持不变,没有更改器方法改变它,也没有方法会生成它的引用,这种情况下同样是安全的。

不过,通常子对象都是可变的,必须重新定义 clone 方法来建立一个深拷贝(deep copy)

这会克隆所有子对象。在这个例子中,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方法来克隆它自己的对象。必须重新定义clonepublic才允许所有方法克隆对象。

在这里,Cloneable接口的出现与接口的正常使用并没有关系。具体来说,它没有指定clone方法,这个方法是从Object类继承的。这个接口只是作为一个标记,指示类设计者了解克隆过程。对象对于克隆很“偏执”,如果一个对象请求克隆,但是没有实现这个接口,就会生成一个检查型异常。

注释Cloneable接口是 Java 提供的少数标记接口(tagging interface)之一。(有些程序员称之为记号接口(marker interface)。)应该记得,Comparable等接口的通常用途是确保一个类实现一个特定的方法或一组方法。标记接口不包含任何方法,它唯一的作用

就是允许在类型查询中使用instanceof

1
if (obj instanceof Cloneable)  

建议你自己的程序中不要使用标记接口。

即使clone的默认(浅拷贝)实现能够满足要求,还是需要实现Cloneable接口,将clone重新定义为public,再调用super.clone()。下面给出一个例子:

1
2
3
4
5
6
class Employee implements Cloneable {
// public access, change return type
public Employee clone() throws CloneNotSupportedException {
return (Employee) super.clone();
}
}

注释:在 Java 1.4 之前,clone 方法的返回类型总是Object,而现在可以为你的clone方法指定正确的返回类型。这是协变返回类型的一个例子(参见第5章)。

Object.clone 提供的浅拷贝相比,前面看到的 clone 方法并没有增加任何功能。这里只是让这个方法是的公共的。要建立深拷贝,还需要做更多工作,克隆对象中可变的实例字段。

下面来看创建深拷贝的 clone 方法的一个例子:

1
2
3
4
5
6
7
8
9
10
11
class Employee implements Cloneable {
public Employee clone() throws CloneNotSupportedException {
// call Object.clone()
Employee cloned = (Employee) super.clone();

// clone mutable fields
cloned.hireDay = (Date) hireDay.clone();

return cloned;
}
}

Object 类的 clone 方法有可能抛出一个 CloneNotSupportedException,如果在一个对象上调用 clone,但这个对象的类并没有实现 Cloneable 接口,就会发生这种情况。当然,EmployeeDate 类实现了 Cloneable 接口,所以不会抛出这个异常。不过,编译器并不知道这一点,因此,我们声明了这个异常:

1
public Employee clone() throws CloneNotSupportedException

注释:捕获这个异常是不是更好一些?

1
2
3
4
5
6
7
8
9
10
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

注释:所有数组类型都有一个公共的 clone 方法,而不是受保护的。可以用这个方法建立一个新数组,包含原数组所有元素的副本。例如:

1
2
3
int[] luckyNumbers = { 2, 3, 5, 7, 11, 13 };
int[] cloned = luckyNumbers.clone();
cloned[5] = 12; // doesn't change luckyNumbers[5]

6.2 lambda表达式

接下来几节中,你会了解如何使用 lambda 表达式采用一种简洁的语法定义代码块,以及如何编写处理 lambda 表达式的代码。

6.2.1 为什么引入 lambda 表达式

lambda 表达式是一个可传递的代码块,可以在以后执行一次或多次。在具体介绍语法(甚至解释这个让人好奇的名字)之前,下面先退一步,观察一下我们在 Java 中的哪些地方用过这种代码块。

在 6.1.7 节中,你已经了解了如何按指定时间间隔完成工作。将这个工作放在一个 ActionListeneractionPerformed 方法中:

1
2
3
4
5
class Worker implements ActionListener {
public void actionPerformed(ActionEvent event) {
// do some work
}
}

然后,想要反复执行这个代码时,可以构造 Worker 类的一个实例,再把这个实例提交到一个 Timer 对象。

这里的重点是:actionPerformed 方法包含希望以后执行的代码。

或者可以考虑如何用一个定制比较器完成排序。如果想按长度而不是默认的字典顺序对字符串进行排序,可以向 sort 方法传入一个 Comparator 对象:

1
2
3
4
5
6
7
class LengthComparator implements Comparator<String> {
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 使用可能很不方便。

6.2.2 lambda 表达式的语法

再来考虑上一节讨论的排序例子。我们传入代码来检查一个字符串是否比另一个字符串短。

这里要计算:

1
first.length() - second.length()

firstsecond 是什么?它们都是字符串。Java 是一种强类型语言,所以我们还要指定它们的类型:

1
2
(String first, String second) ->
first.length() - second.length()

这就是你看到的第一个 lambda 表达式。lambda 表达式就是一个代码块,以及必须传入代码的所有变量的规范。

逻辑学家 Alonzo Church 想要形式化地表示能有效计算的数学函数。他使用了希腊字母 lambda(λ)来标记参数。如果他知道 Java API,可能就会写为

1
λfirstsecond.first.length() - second.length()

注释:为什么是字母 λ?Church 已经把字母表里的所有其他字母都用完了吗?实际上,权威的《数学原理》(Principia Mathematica)一书中就使用重音符 ^ 来表示自由变量,受此启发,Church 使用大写 lambda(Λ)表示参数。不过,最后他还是改为使用小写的 lambda(λ)。从那以后,带参数变量的表达式就被称为 lambda 表达式。

你已经见过 Java 中一种简单的 lambda 表达式形式:参数,箭头(->)以及一个表达式。

如果代码要完成的计算无法放在一个表达式中,就可以像写方法一样,把这些代码放在 {} 中,并包含显式的 return 语句。例如:

1
2
3
4
5
(String first, String second) -> {
if (first.length() < second.length()) return -1;
else if (first.length() > second.length()) return 1;
else return 0;
}

即使 lambda 表达式没有参数,仍然要提供空括号,就像无参数方法一样:

1
2
3
() -> {
for (int i = 100; i >= 0; i--) System.out.println(i);
}

如果可以推导出一个 lambda 表达式的参数类型,则可以忽略其类型。例如:

1
2
3
Comparator<String> comp =
(first, second) // same as (String first, String second) ->
first.length() - second.length();

在这里,编译器可以推导出 firstsecond 必然是字符串,因为这个 lambda 表达式将赋给一个字符串比较器。(下一节会更详细地分析这个赋值。)

如果方法只有一个参数,而且这个参数的类型可以推导得出,那么甚至还可以省略小括号

1
2
3
ActionListener listener = event ->
System.out.println("The time is " +
Instant.ofEpochMilli(event.getWhen()));

instead of (event)->... or (ActionEvent event)->...

**无须指定 lambda 表达式的返回类型。lambda 表达式的返回类型总是会由上下文推导得出。**例如,下面的表达式

1
(String first, String second) -> first.length() - second.length()

可以在需要 int 类型结果的上下文中使用。

最后,可以使用 var 指示一个推导的类型。这不常见,发明这个语法是为了关联注解:

1
(@NonNull var first, @NonNull var second) -> first.length() - second.length()

**注释:如果一个 lambda 表达式只在某些分支返回一个值,而另外一些分支不返回值,这是不合法的。**例如,(int x)-> { if (x >= 0) return 1; } 就不合法。

6.2.3 函数式接口

前面已经讨论过,Java 中有很多封装代码块的接口,如 ActionListenerComparator。lambda 表达式与这些接口是兼容的。

对于只有一个抽象方法的接口,需要这种接口的对象时,就可以提供一个 lambda 表达式。这种接口称为函数式接口(functional interface)。

**注释:**你可能想知道为什么函数式接口必须有一个抽象方法。不是接口中的所有方法都是抽象的吗?实际上,接口完全有可能重新声明 Object 类的方法,如 toStringclone,这些声明有可能会让方法不再是抽象的。(Java API 中的一些接口会重新声明 Object 方法来附加 javadoc 注释。Comparator API 就是这样一个例子。)更重要的是,正如 6.1.5 节所述,接口可以声明非抽象方法。

为了展示如何转换为函数式接口,下面考虑 Arrays.sort 方法。它的第二个参数需要一个 Comparator 实例,Comparator 就是只有一个方法的接口,所以可以提供一个 lambda 表达式:

1
2
Arrays.sort(words,
(first, second) -> first.length() - second.length());

在底层,Arrays.sort 方法会接收实现了 Comparator<String> 的某个类的对象。在这个对象上调用 compare 方法会执行这个 lambda 表达式的体。这些对象和类的管理完全取决于具体实现,与使用传统的内联类相比,这样可能要高效得多。最好把 lambda 表达式看作是一个函数,而不是一个对象,另外要接受一个事实:lambda 表达式可以传递到函数式接口。

lambda 表达式可以转换为接口,这一点让 lambda 表达式很有吸引力。具体的语法很简短。下面再来看一个例子:

1
2
3
4
var timer = new Timer(1000, event ->
System.out.println("At the tone, the time is " +
Instant.ofEpochMilli(event.getWhen()));
Toolkit.getDefaultToolkit().beep();

与使用实现了 ActionListener 接口的类相比,这段代码的可读性要好得多。

实际上,在 Java 中,对 lambda 表达式所能做的也只是转换为函数式接口。在其他支持函数字面量的程序设计语言中,可以声明函数类型(如 (String, String) -> int),声明这些类型的变量,还可以使用变量保存函数表达式。不过,Java 设计者还是决定保持我们熟悉的接口概念,而没有为 Java 语言增加函数类型。

注释甚至不能把 lambda 表达式赋给类型为 Object 的变量,Object 不是一个函数式接口。

Java API 在 java.util.function 包中定义了很多非常通用的函数式接口。其中一个接口 BiFunction<T, U, R> 描述了参数类型为 T 和 U 而且返回类型为 R 的函数。可以把我们的字符串比较 lambda 表达式保存在这个类型的变量中:

1
2
BiFunction<String, String, Integer> comp =
(first, second) -> first.length() - second.length();

不过,这对于排序并没有帮助。没有哪个 Arrays.sort 方法想要接收一个 BiFunction。如果你之前用过某种函数式编程语言,可能会发现这很奇怪。不过,对于 Java 程序员而言,这非常自然。类似 Comparator 的接口往往有一个特定的用途,而不只是提供一个有指定参数和返回类型的方法。想要用 lambda 表达式做某些处理时,还是希望谨记表达式的用途,为它建立一个特定的函数式接口。

java.util.function 包中有一个尤其有用的接口 Predicate

1
2
3
4
public interface Predicate<T> {
boolean test(T t);
// additional default and static methods
}

ArrayList 类有一个 removeIf 方法,它的参数就是一个 Predicate。这个接口专门用来传递 lambda 表达式。例如,下面的语句将从一个数组列表删除所有 null 值:

1
list.removeIf(e -> e == null);

另一个有用的函数式接口是 Supplier<T>

1
2
3
public interface Supplier<T> {
T get();
}

供应者(supplier)没有参数,调用时会生成一个 T 类型的值。供应者用于实现懒计算(lazy evaluation)。例如,考虑以下调用:

1
2
LocalDate hireDay = Objects.requireNonNullElse(day,
LocalDate.of(1970, 1, 1));

这不是最优的。我们预计 day 很少为 null,所以希望只在必要时才构造默认的 LocalDate。通过使用供应者,我们就能延迟这个计算:

1
2
LocalDate hireDay = Objects.requireNonNullElseGet(day,
() -> LocalDate.of(1970, 1, 1));

requireNonNullElseGet 方法只在需要值时才调用供应者。

6.2.4 方法引用

有时,lambda 表达式涉及一个方法。例如,假设你希望只要出现一个定时器事件就打印这个事件对象。当然,为此也可以调用:

1
var timer = new Timer(1000, event -> System.out.println(event));

但是,如果直接把 println 方法传递到 Timer 构造器就更好了。具体做法如下:

1
var timer = new Timer(1000, System.out::println);

表达式 System.out::println 是一个方法引用(method reference),它指示编译器生成一个函数式接口的实例,覆盖这个接口的抽象方法来调用给定的方法。在这个例子中,会生成一个 ActionListener,它的 actionPerformed(ActionEvent e) 方法要调用 System.out.println(e)

**注释:类似于 lambda 表达式,方法引用也不是一个对象。**不过,为一个类型为函数式接口的变量赋值时会生成一个对象。

注释:PrintStream 类(System.out 就是 PrintStream 类的一个实例)中有 10 个重载的 println 方法。编译器需要根据上下文确定使用哪一个方法。在我们的例子中,方法引用 System.out::println 必须转换为一个包含以下方法的 ActionListener 实例:

1
void actionPerformed(ActionEvent e)

这样会从 10 个重载的 println 方法中选出 println(Object x) 方法,因为 ObjectActionEvent 最匹配。调用 actionPerformed 方法时,就会打印这个事件对象。

现在假设我们把同样的这个方法引用赋给另一个函数式接口:

1
Runnable task = System.out::println;

这个 Runnable 函数式接口有一个无参数的抽象方法:

1
void run();

在这种情况下,会选择无参数的 println 方法。调用 task.run() 会向 System.out 打印一个空行。

再来看一个例子,假设你想对字符串进行排序,而不考虑字母的大小写。可以传递以下方法引用:

1
Arrays.sort(strings, String::compareToIgnoreCase);

从这些例子可以看出,要用 :: 操作符分隔方法名与对象或类名。主要有 3 种情况:

  1. object::instanceMethod
  2. Class::instanceMethod
  3. Class::staticMethod

在第 1 种情况下,方法引用等价于一个 lambda 表达式,其参数要传递到方法。对于 System.out::println,对象是 System.out,所以这个方法引用等价于 x -> System.out.println(x)

对于第 2 种情况,第 1 个参数会成为方法的隐式参数。例如,String::compareToIgnoreCase等同于(x, y) -> x.compareToIgnoreCase(y)

在第3种情况下,所有参数都传递到静态方法:Math::pow 等价于 (x, y) -> Math.pow(x, y)

注意,只有当 lambda 表达式的体只调用一个方法而不做其他操作时,才能把 lambda 表达式重写为方法引用。考虑以下 lambda 表达式:

1
s -> s.length() == 0

这里有一个方法调用。但是还有一个比较,所以这里不能使用方法引用。

注释

  • 如果有多个同名的重载方法,编译器就会尝试从上下文中找出你指的是哪一个方法。例如,Math.max 方法有两个版本,一个用于整数,另一个用于 double 值。选择哪一个版本取决于 Math::max 转换为哪个函数式接口的方法参数。类似于 lambda 表达式,方法引用不会独立存在,总是会转换为函数式接口的实例。

  • 有时 API 包含一些专门用作方法引用的方法。例如,Objects 类有一个方法 isNull,用于测试一个对象引用是否为 null。乍看上去这好像没有什么用,因为测试 obj == nullObjects.isNull(obj) 更有可读性。不过可以把方法引用传递到任何有 Predicate 参数的方法。例如,要从一个列表删除所有 null 引用,就可以调用:

  •   list.removeIf(Objects::isNull);
      //这比 list.removeIf(e -> e == null)更易读。
      
    1
    2
    3
    4
    5
    6
    7
    8
    9

    ![image-20251116165653881](image-20251116165653881.png)

    **注释:**包含对象的方法引用与等价的 lambda 表达式还有一个细微的差别。考虑一个方法引用,如 `separator::equals`。如果 `separator` 为 `null`,构造 `separator::equals` 时就会立即抛出一个 `NullPointerException` 异常。而 lambda 表达式 `x -> separator.equals(x)` 只在调用时才会抛出 `NullPointerException`。

    可以在方法引用中使用 `this` 参数。例如,`this::equals` 等同于 `x -> this.equals(x)`。使用 `super` 也是合法的。下面的方法表达式:

    ```java
    super::instanceMethod

使用 this 作为目标,会调用给定方法的超类版本。为了展示这一点,下面给出一个假想的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Greeter {
public void greet(ActionEvent event) {
System.out.println("Hello, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
}
}

class RepeatedGreeter extends Greeter {
public void greet(ActionEvent event) {
var timer = new Timer(1000, super::greet);
timer.start();
}
}

RepeatedGreeter.greet 方法开始执行时,会构造一个 Timer,每次定时器滴答时会执行 super::greet 方法。

6.2.5 构造器引用

构造器引用与方法引用很类似,只不过方法名为 new。例如,Person::newPerson 构造器的一个引用。哪一个构造器呢?这取决于上下文。假设你有一个字符串列表。可以在各个字符串上调用构造器,把这个字符串列表转换为一个 Person 对象数组,调用如下:

1
2
3
ArrayList<String> names = ...;
Stream<Person> stream = names.stream().map(Person::new);
List<Person> people = stream.toList();

map 方法会为各个列表元素调用 Person(String) 构造器。如果有多个 Person 构造器,编译器会选择有一个 String 参数的构造器,因为它从上下文推导出这是在调用带一个字符串的构造器。

可以用数组类型建立构造器引用。例如,int[]::new 是一个构造器引用,它有一个参数:数组的长度。这等价于 lambda 表达式 x -> new int[x]

**Java 有一个限制:无法构造泛型类型 T 的数组。**数组构造器引用对于克服这个限制很有用。(表达式 new T[n] 会产生错误,因为这会“擦除”为 new Object[n])。

对于开发类库的人来说,这是一个问题。例如,假设我们需要一个 Person 对象数组。Stream 接口有一个 toArray 方法可以返回 Object 数组:

1
Object[] people = stream.toArray();

用户希望得到一个 Person 引用数组,而不是 Object 引用数组。

流库利用构造器引用解决了这个问题。可以把 Person[]::new 传入 toArray 方法:

1
Person[] people = stream.toArray(Person[]::new);

toArray 方法调用这个构造器来得到一个有正确类型的数组。然后填充并返回这个数组。

6.2.6 变量作用域

你可能希望能够在 lambda 表达式中访问外围方法或类中的变量。考虑下面这个例子:

1
2
3
4
5
6
7
public static void repeatMessage(String text, int delay) {
ActionListener listener = event -> {
System.out.println(text);
Toolkit.getDefaultToolkit().beep();
};
new Timer(delay, listener).start();
}

来看这样一个调用:

1
repeatMessage("Hello", 1000); // prints Hello every 1000 milliseconds

现在来看 lambda 表达式中的变量 text。注意这个变量并不是在这个 lambda 表达式中定义的。实际上,这是 repeatMessage 方法的一个参数变量。

lambda 表达式的代码可能在 repeatMessage 调用返回很久以后才运行,而那时这个参数变量已经不存在了。text 变量是如何保留下来的呢?

lambda 表达式的组成:

  1. 一个代码块;
  2. 参数;
  3. 自由变量的值,这是指非参数而且不在代码中定义的变量。

在我们的例子中,这个 lambda 表达式有一个自由变量 text。表示 lambda 表达式的数据结构必须存储自由变量的值,在这里就是字符串 "Hello"。我们说这些值被 lambda 表达式捕获(captured)

注释:关于代码块连同自由变量值有一个术语:闭包(closure)。在 Java 中,lambda 表达式就是闭包。

**可以看到,lambda 表达式可以捕获外围作用域中变量的值。**在 Java 中,为了确保所捕获的值是明确定义的,这里有一个重要的限制。**在 lambda 表达式中,只能引用值不会改变的变量。**例如,下面的做法是不合法的:

1
2
3
4
5
6
7
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 表达式中更改变量,并发执行多个动作时就会不安全。

另外,**如果在 lambda 表达式中引用一个变量,而这个变量可能在外部改变,这也是不合法的。**例如,下面就是不合法的:

1
2
3
4
5
6
7
public static void repeat(String text, int count) {
for (int i = 1; i <= count; i++) {
ActionListener listener = event ->
System.out.println(i + ":" + text); // ERROR: Cannot refer to changing i
new Timer(1000, listener).start();
}
}

这里有一条规则:lambda 表达式中捕获的变量必须是事实最终变量(effectively final)。**事实最终变量是指,这个变量初始化之后就不会再为它赋新值。**在这里,text 总是指示同一个 String 对象,所以捕获这个变量是可以的。不过,i 的值会改变,因此不能捕获 i

lambda 表达式的体与嵌套块有相同的作用域。这里同样适用命名冲突和遮蔽的有关规则。在 lambda 表达式中声明与一个局部变量同名的参数或局部变量是不合法的。

1
2
3
Path first = Path.of("/usr/bin");
Comparator<String> comp =
(first, second) -> first.length() - second.length(); // ERROR: Variable first already defined

在一个方法中,不能有两个同名的局部变量,因此,lambda 表达式中同样也不能有同名的局部变量。

**在一个 lambda 表达式中使用 this 关键字时,是指创建这个 lambda 表达式的方法的 this 参数。**例如,考虑下面的代码:

1
2
3
4
5
6
7
8
9
10
public class Application 
{
public void init()
{
ActionListener listener = event ->{
System.out.println(this.toString());
...
}
}
}

表达式 this.toString() 会调用 Application 对象的 toString 方法,而不是 ActionListener 实例的方法。在 lambda 表达式中,this 的使用并没有任何特殊之处。lambda 表达式的作用域嵌套在 init 方法中,不论 this 在 lambda 表达式中,还是出现在这个方法中的其他位置,其含义并没有不同。

6.2.7 处理 lambda 表达式

下面来看如何编写方法处理 lambda 表达式。

使用 lambda 表达式的重点是延迟执行(deferred execution)。毕竟,如果想要立即执行代码,完全可以直接执行,而无须把它包装在一个 lambda 表达式中。之所以希望以后再执行代码,这有很多原因,如:

  • 在一个单独的线程中运行代码;
  • 多次运行代码;
  • 在算法的适当位置运行代码(例如,排序中的比较操作);
  • 发生某种情况时运行代码(如,点击了一个按钮,数据已经到达,等等);
  • 只在必要时才运行代码。

假设你想要重复一个动作 n 次。将这个动作和重复次数传递到一个 repeat 方法:

1
repeat(10, () -> System.out.println("Hello, World!"));

要接受这个 lambda 表达式,需要选择(偶尔可能需要提供)一个函数式接口。表6-2列出了 Java API 中提供的最重要的函数式接口。在这里,我们可以使用 Runnable 接口:

1
2
3
4
public static void repeat(int n, Runnable action) 
{
for (int i = 0; i < n; i++) action.run();
}

image-20251117155511784

image-20251117155518998

需要说明,调用 action.run() 时会执行这个 lambda 表达式的主体。

我们希望告诉这个动作它出现在哪一次迭代中。为此,需要选择一个合适的函数式接口,其中要包含一个方法,这个方法有一个 int 参数而且返回类型为 void。处理 int 值的标准接口如下:

1
2
3
public interface IntConsumer {
void accept(int value);
}

下面给出 repeat 方法的改进版本:

1
2
3
public static void repeat(int n, IntConsumer action) {
for (int i = 0; i < n; i++) action.accept(i);
}

可以如下调用:

1
repeat(10, i -> System.out.println("Countdown: " + (9 - i)));

表6-3列出了基本类型 intlongdouble 的 34 个可用的特殊化接口。使用这些特殊化接口比使用通用接口更高效。

image-20251117155701856

image-20251117155714497

提示:最好使用表6-2或表6-3中的接口。例如,假设要编写一个方法来处理满足某个特定条件的文件。对此有一个遗留接口 java.io.FileFilter,不过最好使用标准的 Predicate<File>。只有一种情况下可以不这么做,那就是你已经有很多有用的方法可以产生 FileFilter 实例。

注释:**大多数标准函数式接口都提供了非抽象方法来生成或合并函数。**例如:

  • Predicate.isEqual(a) 等同于 a::equals,不过如果 anull 也能正常工作。
  • 已经提供了默认方法 andornegate 来合并谓词。例如:
1
Predicate.isEqual(a).or(Predicate.isEqual(b))

就等同于:

1
x -> a.equals(x) || b.equals(x)

注释:如果你设计你自己的接口,其中只有一个抽象方法,可以用 @FunctionalInterface 注解来标记这个接口。这样做有两个优点:

  1. 如果你无意中增加了另一个抽象方法,编译器会给出一个错误消息。
  2. 另外 javadoc 页中会指出你的接口是一个函数式接口。

并不是必须使用注解。根据定义,任何只有一个抽象方法的接口都是函数式接口。不过使用 @FunctionalInterface 注解确实是一个好主意。

注释:有些程序员喜欢将方法调用串起来,如:

1
2
3
4
String input = "618970019642690137449562111";
boolean isPrime = input.strip()
.transform(BigInteger::new)
.isProbablePrime(20);

String 类的 transform 方法(Java 12 中新增)对字符串应用一个 Function,并生成结果。同样地,这些调用也可以写为:

1
boolean prime = new BigInteger(input.strip()).isProbablePrime(20);

不过这样一来,你的视线必须左右跳来跳去,要找出哪一个先执行,哪一个后执行:首先调用 strip,然后构造 BigInteger,最后检测它是否是一个可能的素数。

遗憾的是,它只适用于字符串。

6.2.8 再谈 Comparator

Comparator 接口包含很多方便的静态方法来创建比较器。这些方法可以用于 lambda 表达式或方法引用。

静态 comparing 方法接受一个“键提取器”函数,它将类型 T 映射为一个可比较的类型(如 String)。对要比较的对象应用这个函数,然后对返回的键完成比较。例如,假设有一个 Person 对象数组,可以如下按姓名对这些对象进行排序:

1
Arrays.sort(people, Comparator.comparing(Person::getName));

可以把比较器与 thenComparing 方法串起来,来处理比较结果相同的情况。例如:

1
2
3
Arrays.sort(people,
Comparator.comparing(Person::getLastName)
.thenComparing(Person::getFirstName));

如果两个人的姓相同,就会使用第二个比较器。

这些方法有很多变体形式。可以为 comparingthenComparing 方法提取的键指定一个比较器。例如,可以如下根据人名长度完成排序:

1
2
Arrays.sort(people, Comparator.comparing(Person::getName,
(s, t) -> Integer.compare(s.length(), t.length())));

另外,comparingthenComparing 方法都有变体形式,可以避免 intlongdouble 值的装箱。要完成前一个操作,还有一种更容易的做法:

1
Arrays.sort(people, Comparator.comparingInt(p -> p.getName().length()));

如果键函数可能返回 null,可能就要用到 nullsFirstnullsLast 适配器。这些静态方法会修改现有的比较器,从而在遇到 null 值时不会抛出异常,而是将这个值标记为小于或大于正常值。例如,假设一个人没有中名时 getMiddleName 会返回一个 null,就可以使用:

1
Comparator.comparing(Person::getMiddleName, Comparator.nullsFirst(...));

nullsFirst 方法需要一个比较器,在这里就是比较两个字符串的比较器。naturalOrder 方法可以为任何实现了 Comparable 的类建立一个比较器。在这里,Comparator.<String>naturalOrder() 正是我们需要的。下面是一个完整的调用,可以按可能为 null 的中名进行排序。这里使用了静态导入 java.util.Comparator.*,使这个表达式更为简洁,更便于阅读。注意 naturalOrder 的类型会推导得出:

1
Arrays.sort(people, comparing(Person::getMiddleName, nullsFirst(naturalOrder())));

静态 reverseOrder 方法会提供自然顺序的逆序。要让比较器逆序比较,可以使用 reversed 实例方法。例如 naturalOrder().reversed() 等同于 reverseOrder()

6.3 内部类

内部类(inner class)是定义在另一个类中的类。为什么需要使用内部类呢?主要有两个原因:

  • 内部类可以对同一个包中的其他类隐藏。
  • 内部类方法可以访问定义这些方法的作用域中的数据,包括原本私有的数据。

内部类的对象会有一个隐式引用,指向实例化这个对象的外部类对象。通过这个指针,它可以访问外部对象的全部状态。例如,在 Java 中,Iterator 类不需要它所指的 LinkedList 的一个显式指针。

在 Java 中,静态内部类没有这个附加的指针,所以 Java 的静态内部类就相当于 C++ 中的嵌套类。

6.3.1 使用内部类访问对象状态

我们将重构 TimerTest 示例,提取出一个 TalkingClock 类。构造一个语音时钟时需要提供两个参数:发出通知的间隔和开关铃声的标志。

1
2
3
4
5
6
7
8
9
10
11
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 标志。

1
2
3
4
5
6
7
public class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}

令人惊讶的事情发生了。TimePrinter 类没有实例字段或者名为 beep 的变量,实际上,beep 指示创建这个 TimePrinterTalkingClock 对象中的字段。可以看到,一个内部类方法可以访问自身的实例字段,也可以访问创建它的外部类对象的实例字段。

为此,内部类的对象总有一个隐式引用,指向创建它的外部类对象,如图6-3所示。

image-20251117225059058

这个引用在内部类的定义中是不可见的。不过,为了说明这个概念,我们将外部类对象的引用称为 outer。于是 actionPerformed 方法将等价于以下代码:

1
2
3
4
5
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (outer.beep) Toolkit.getDefaultToolkit().beep();
}

外部类的引用在构造器中设置。编译器会修改所有的内部类构造器,添加一个对应外部类引用的参数。因为 TimePrinter 类没有定义构造器,所以编译器为这个类生成了一个无参数构造器,生成的代码如下所示:

1
2
3
public TimePrinter(TalkingClock clock) { // automatically generated code
outer = clock;
}

再次强调,注意 outer 不是 Java 的关键字。我们只是用它说明内部类的有关机制。

start 方法中构造一个 TimePrinter 对象后,编译器就会将当前语音时钟的 this 引用传递给这个构造器:

1
var listener = new TimePrinter(this); // parameter automatically added

下面我们再来看访问控制。如果 TimePrinter 类是一个普通的类,它就需要通过 TalkingClock 类的公共方法访问 beep 标志,而使用内部类是一个改进,现在就不再需要提供只有另外一个类感兴趣的访问器了。

注释:我们也可以把 TimePrinter 类声明为私有(private)。这样一来,只有 TalkingClock 方法才能够构造 TimePrinter 对象。只有内部类可以是私有的,而常规类可以有包可见性或公共可见性。

6.3.2 内部类的特殊语法规则

我们解释了内部类有一个外部类的引用,我们把它叫作 outer。表达式

1
OuterClass.this

表示外部类引用。例如,可以像下面这样编写 TimePrinter 内部类的 actionPerformed 方法:

1
2
3
public void actionPerformed(ActionEvent event) {
if (TalkingClock.this.beep) Toolkit.getDefaultToolkit().beep();
}

反过来,可以采用以下语法更加明确地编写内部类对象的构造器:

1
outerObject.new InnerClass(construction parameters)

例如:

1
ActionListener listener = this.new TimePrinter();

在这里,新构造的 TimePrinter 对象的外部类引用被设置为创建内部类对象的方法的 this 引用。这是最常见的情况。通常,this. 限定符是多余的。不过,也有可能通过显式地命名将外部类引用设置为其他对象。例如,由于 TimePrinter 是一个公共内部类,可以为任意的语音时钟构造一个 TimePrinter

1
2
var jabberer = new TalkingClock(1000, true);
TalkingClock.TimePrinter listener = jabberer.new TimePrinter();

需要注意,在外部类的作用域之外,可以这样引用内部类:

1
OuterClass.InnerClass

注释内部类中声明的所有静态字段都必须是 final,并初始化为一个编译时常量。如果这个字段不是一个常量,就可能不唯一。

内部类不能有 static 方法。Java 语言规范对这个限制没有做任何解释。

6.3.3 内部类是否有用、必要和安全

内部类将转换为常规的类文件,用 $(美元符号)分隔外部类名与内部类名。例如,TalkingClock 类内部的 TimePrinter 类将转换成类文件 TalkingClock$TimePrinter.class。为了查看它的实际工作,可以尝试下面的实验:运行第 5 章中的程序 ReflectionTest,并提供类 TalkingClock$TimePrinter 来完成反射。或者,也可以直接使用 javap 工具,如下所示:

1
javap -private ClassName

会得到以下输出结果:

1
2
3
4
5
6
7
public class innerClass.TalkingClock$TimePrinter
implements java.awt.event.ActionListener
{
final innerClass.TalkingClock this$0;
public innerClass.TalkingClock$TimePrinter(innerClass.TalkingClock);
public void actionPerformed(java.awt.event.ActionEvent);
}

可以清楚地看到,编译器生成了一个额外的实例字段 this$0,对应外部类的引用。(名字 this$0 是编译器合成的,在你自己编写的代码中不能引用这个字段。)另外,还可以看到构造器的 TalkingClock 参数。

如果编译器能够自动完成这个转换,那么能不能自己编写程序实现这种机制呢?让我们试试看。将 TimePrinter 定义成一个常规类,把它置于 TalkingClock 类的外部。在构造 TimePrinter 对象的时候,传入创建它的对象的 this 指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TalkingClock {
public void start() {
var listener = new TimePrinter(this);
var timer = new Timer(interval, listener);
timer.start();
}
}

class TimePrinter implements ActionListener {
private TalkingClock outer;

public TimePrinter(TalkingClock clock) {
outer = clock;
}
}

现在来看 actionPerformed 方法,它需要访问 outer.beep

1
if (outer.beep) ... // ERROR

这就遇到了一个问题。内部类可以访问外部类的私有数据,但我们的外部 TimePrinter 类则不行。

在 Java 11 之前,内部类纯粹是一种编译器现象,虚拟机对它们并没有任何特别的了解。那时,如果用 ReflectionTest 程序查看 TalkingClock 类,或者使用 javap 并提供 -private 选项来查看,会显示以下结果:

1
2
3
4
5
6
7
8
class TalkingClock
{
private int interval;
private boolean beep;
public TalkingClock(int, boolean);
static boolean access$0(TalkingClock); // Prior to Java 11
public void start();
}

请注意编译器在外部类中添加的静态方法 access$0。它将返回作为参数传递的那个对象的 beep 字段。(方法名可能稍有不同,如可能是 access$000,这取决于你的编译器。)

在 Java 11 中,虚拟机了解类之间的嵌套关系,不再生成访问方法。

6.3.4 局部内部类

如果仔细查看 TalkingClock 示例的代码就会发现,类型 TimePrinter 的名字只出现了一次:就是在 start 方法中创建这个类型的对象时使用了一次。

在类似这样的情况下,可以在一个方法中局部地定义这个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
public void start() {
class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}

var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}

声明局部类时不能有访问说明符(即 publicprivate)。局部类的作用域总是限定在声明这个局部类的块中。

局部类有一个很大的优势,即对外部世界完全隐藏,甚至 TalkingClock 类中的其他代码也不能访问它。除 start 方法之外,没有任何方法知道 TimePrinter 类的存在。

6.3.5 由外部方法访问变量

与其他内部类相比较,局部类还有另外一个优点。它们不仅能够访问外部类的字段,还可以访问局部变量!不过,那些局部变量必须是事实最终变量(effectively final)。这说明,它们一旦赋值就绝不会改变。

下面是一个典型的示例。这里,将 TalkingClock 构造器的参数 intervalbeep 移至 start 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void start(int interval, boolean beep) {
class TimePrinter implements ActionListener {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
}

var listener = new TimePrinter();
var timer = new Timer(interval, listener);
timer.start();
}

请注意,TalkingClock 类不再需要存储 beep 实例字段。它只是引用 start 方法的 beep 参数变量。

这看起来好像没什么值得大惊小怪的。毕竟,下面这行代码

1
if (beep) ...

最后总会在 start 方法中,为什么不能访问 beep 变量的值呢?

为了能够清楚地看到这里一个微妙的问题,让我们仔细考虑这个控制流程:

  1. 调用 start 方法。
  2. 调用内部类 TimePrinter 的构造器,从而初始化对象变量 listener
  3. listener 引用传递给 Timer 构造器,定时器开始计时,start 方法退出。此时,start 方法的 beep 参数变量不复存在。
  4. 1 秒之后,actionPerformed 方法执行 if (beep)

要让 actionPerformed 方法中的代码正常工作,TimePrinter 类必须在 beep 参数值消失之前将 beep 字段复制为 start 方法的一个局部变量。实际上也是这样做的。在我们的例子中,编译器为局部内部类合成了名字 TalkingClock$1TimePrinter。如果再次使用 ReflectionTest 程序或者 javap 工具查看 TalkingClock$1TimePrinter 类,就会看到以下结果:

1
2
3
4
5
6
class TalkingClock$1TimePrinter {
TalkingClock$1TimePrinter(boolean);
public void actionPerformed(java.awt.event.ActionEvent);
final boolean val$beep;
final TalkingClock this$0;
}

创建一个对象的时候,beep 变量的当前值会存储在 val$beep 字段中。在 Java 11 中,可以利用“嵌套伴侣”(nestmate)访问来实现。之前,内部类构造器有一个额外的参数来设置这个字段。不论采用哪种方法,即使局部变量出了作用域,内部类字段都将持久保存。

6.3.6 匿名内部类

使用局部内部类时,通常还可以再进一步。假如只想创建这个类的一个对象,甚至不需要为类指定名字。这样一个类被称为匿名内部类(anonymous inner class)

1
2
3
4
5
6
7
8
9
10
11
public void start(int interval, boolean beep) {
var listener = new ActionListener() {
public void actionPerformed(ActionEvent event) {
System.out.println("At the tone, the time is "
+ Instant.ofEpochMilli(event.getWhen()));
if (beep) Toolkit.getDefaultToolkit().beep();
}
};
var timer = new Timer(interval, listener);
timer.start();
}

这个语法确实很晦涩难懂。它的含义是:创建一个类的新对象,这个类实现了 ActionListener 接口,需要实现的方法 actionPerformed 是在大括号 {} 中定义的方法。

一般地,语法如下:

1
2
3
new SuperType(construction parameters) {
inner class methods and data
}

在这里,SuperType 可以是接口,如 ActionListener,如果是这样,内部类就要实现这个接口。SuperType 也可以是一个类,如果是这样,内部类就要扩展这个类。

**由于构造器的名字必须与类名相同,而匿名内部类没有类名,所以,匿名内部类不能有构造器。**实际上,构造参数要传递给超类(superclass)构造器。具体地,只要内部类实现一个接口,就不能有任何构造参数。不过,仍然要提供一组小括号,如下所示:

1
2
3
new InterfaceType() {
methods and data
}
1
2
var queen = new Person("Mary");         // a Person object
var count = new Person("Dracula") { ... }; // an object of an inner class extending Person

如果构造参数列表的结束小括号后面跟一个开始大括号,就是在定义匿名内部类。

注释尽管匿名类不能有构造器,但可以提供一个对象初始化块:

1
2
3
var count = new Person("Dracula") {
// initialization
};

注释:如果将一个匿名类实例存储在用 var 定义的一个变量中,这个变量会“了解”增加的方法或字段:

1
2
var bob = new Object() { String name = "Bob"; };
System.out.println(bob.name);

如果声明 bob 的类型为 Objectbob.name 将无法编译。

new Object() { String name = "Bob"; } 构造的对象类型为“有一个 String name 字段的 Object”。这是一个**“不可指示的”(nondenotable)**类型,即无法用 Java 语法表示的一个类型。不过,编译器理解这个类型,可以为 bob 变量设置这个类型。

注释:下面的技巧称为**“双括号初始化”**(double brace initialization),这里利用了内部类语法。假设你想构造一个数组列表,并将它传递到一个方法:

1
2
3
4
var friends = new ArrayList<String>();
friends.add("Harry");
friends.add("Tony");
invite(friends);

如果不再需要这个数组列表,最好让它作为一个匿名列表。不过作为一个匿名列表,该如何为它添加元素呢?方法如下:

1
2
3
4
invite(new ArrayList<String>() {{
add("Harry");
add("Tony");
}});

注意这里的双括号。外层括号建立了 ArrayList 的一个匿名子类。内层括号则是一个对象初始化块。

在实际中,这个技巧很少使用。大多数情况下,invite 方法会接受任何 List<String>,所以可以直接传入:

1
List.of("Harry", "Tony")

警告:建立一个与超类大体类似(但并不完全相同)的匿名子类通常会很方便。不过对于 equals 方法要特别当心。第 5 章中,我们曾建议 equals 方法使用以下测试:

1
if (getClass() != other.getClass()) return false;

但是对于匿名子类,这个测试会失败。

💡 提示:生成日志或调试消息时,通常希望包含当前类的类名,如:

1
System.err.println("Something awful happened in " + getClass());

不过,这对于静态方法不奏效。毕竟,调用 getClass() 时调用的是 this.getClass(),而静态方法没有 this。所以应该使用以下表达式:

1
new Object() {}.getClass().getEnclosingClass() // gets class of static method

在这里,new Object() {} 会建立 Object 的匿名子类的一个匿名对象,getEnclosingClass() 则得到其外围类,也就是包含这个静态方法的类。

6.3.7 静态内部类

有时候,使用内部类只是为了把一个类隐藏在另外一个类的内部,并不需要内部类有外部类对象的一个引用。为此,可以将内部类声明为 static,这样就不会生成那个引用。

下面是一个想要使用静态内部类的典型例子。考虑这样一个任务:计算数组中的最小值和最大值。当然,可以编写两个方法,一个方法用于计算最小值,另一个方法用于计算最大值。在调用这两个方法的时候,数组被遍历两次。如果只遍历数组一次,同时计算出最小值和最大值,这样会更为高效。

1
2
3
4
5
6
double min = Double.POSITIVE_INFINITY;
double max = Double.NEGATIVE_INFINITY;
for (double v : values) {
if (min > v) min = v;
if (max < v) max = v;
}

不过,这个方法必须返回两个数,为此,可以定义一个包含两个值的类 Pair

1
2
3
4
5
6
7
8
9
10
11
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 类型的对象。

1
2
3
4
5
6
class ArrayAlg {
public static Pair minmax(double[] values) {
// ... 计算 min 和 max ...
return new Pair(min, max);
}
}

这个方法的调用者可以使用 getFirstgetSecond 方法获得答案:

1
2
3
Pair p = ArrayAlg.minmax(d);
System.out.println("min = " + p.getFirst());
System.out.println("max = " + p.getSecond());

当然,Pair 是一个十分大众化的名字。在大型项目中,其他程序员也很有可能使用这个名字,只不过可能会定义一个 Pair 类包含一对字符串。这样就会产生名字冲突,解决这个问题的办法是将 Pair 定义为 ArrayAlg 的一个公共内部类。这样一来,就可以通过 ArrayAlg.Pair 访问这个类了:

1
ArrayAlg.Pair p = ArrayAlg.minmax(d);

不过,与前面例子中所使用的内部类不同,我们不希望 Pair 对象中有其他对象的引用,为此,可以将这个内部类声明为 static,从而不生成那个引用:

1
2
3
4
5
class ArrayAlg {
public static class Pair {
// ...
}
}

当然,只有内部类可以声明为 static。静态内部类就类似于其他内部类,只不过静态内部类的对象没有其外部类对象的引用。在我们的示例中,必须使用静态内部类,这是因为内部类对象是在一个静态方法中构造的:

1
2
3
public static Pair minmax(double[] d) {
return new Pair(min, max);
}

如果没有将 Pair 类声明为 static,那么编译器将会报错,指出没有可用的 ArrayAlg 类型隐式对象来初始化内部类对象。

注释:只要内部类不需要访问外部类对象,就应该使用静态内部类。有些程序员用嵌套类(nested class)表示静态内部类。

注释:与常规内部类不同,静态内部类可以有静态字段和方法。

注释:在接口中声明的内部类自动是 staticpublic

注释:类中声明的接口、记录和枚举都自动为 static

6.4 服务加载器

JDK 提供了一个加载服务的简单机制(这里会介绍)。这种机制由 Java 平台模块系统提供支持。

提供一个服务时,程序通常希望服务设计者对于如何实现这个服务的特性能有一些自由。另外还希望有多个实现可供选择。利用 ServiceLoader 类可以很容易地加载符合一个公共接口的服务。

**定义一个接口(或者,如果愿意,也可以定义一个超类),其中包含这个服务的各个实例应当提供的方法。**例如,假设你的服务要提供加密。

1
2
3
4
5
6
7
package serviceLoader;

public interface Cipher {
byte[] encrypt(byte[] source, byte[] key);
byte[] decrypt(byte[] source, byte[] key);
int strength();
}

服务提供者可以提供一个或多个实现这个服务的类,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package serviceLoader.impl;

public class CaesarCipher implements Cipher {
public byte[] encrypt(byte[] source, byte[] key) {
var result = new byte[source.length];
for (int i = 0; i < source.length; i++)
result[i] = (byte)(source[i] + key[0]);
return result;
}

public byte[] decrypt(byte[] source, byte[] key) {
return encrypt(source, new byte[] { (byte) -key[0] });
}

public int strength() { return 1; }
}

实现类可以放在任意的包中,而不一定是服务接口所在的包。每个实现类必须有一个无参数构造器。

现在把这些类的类名增加到 META-INF/services 目录下的一个 UTF-8 编码的文本文件中,文件名必须与接口的完全限定名一致。在我们的例子中,文件 META-INF/services/serviceLoader.Cipher 必须包含这样一行:

1
serviceLoader.impl.CaesarCipher

在这个例子中,我们提供了一个实现类。你也可以提供多个类,以后可以从中选择。

完成这个准备工作之后,程序可以如下初始化一个服务加载器:

1
public static ServiceLoader<Cipher> cipherLoader = ServiceLoader.load(Cipher.class);

这个初始化工作只在程序中完成一次。

服务加载器的 iterator 方法会返回一个迭代器来迭代处理所提供的所有服务实现。最容易的做法是使用一个增强的 for 循环进行遍历。在循环中,选择一个适当的对象来完成服务。

1
2
3
4
5
public static Cipher getCipher(int minStrength) {
for (Cipher cipher : cipherLoader) // implicitly calls cipherLoader.iterator()
if (cipher.strength() >= minStrength) return cipher;
return null;
}

或者,也可以使用流查找我所要的服务。stream() 方法会生成 ServiceLoader.Provider 实例的一个流。这个接口包含 type()get() 方法,可以用来得到提供者类和提供者实例。如果按类型选择一个提供者,只需要调用 type(),而没有必要实例化任何服务实例。

1
2
3
4
5
6
public static Optional<Cipher> getCipher2(int minStrength) {
return cipherLoader.stream()
.filter(descr -> descr.type() == serviceLoader.impl.CaesarCipher.class)
.findFirst()
.map(ServiceLoader.Provider::get);
}

最后,如果想要得到任何服务实例,只需要调用 findFirst()

1
Optional<Cipher> cipher = cipherLoader.findFirst();

6.5 代理

在本章的最后,我们来讨论代理(proxy)。利用代理可以在运行时创建实现了一组给定接口的新类。只有在编译时无法确定需要实现哪个接口时才有必要使用代理。

6.5.1 何时使用代理

假设你想构造一个类的对象,这个类实现了一个或多个接口,但是在编译时你可能并不知道这些接口到底是什么。这个问题确实有些难度。要想构造一个具体的类,只需要使用 newInstance 方法或者使用反射找出构造器。但是,不能实例化接口。需要在运行的程序中定义一个新类。

为了解决这个问题,有些程序会生成代码,将这些代码放在一个文件中,调用编译器,然后再加载得到的类文件。很自然地,这样做的速度会比较慢,并且需要部署编译器以及程序。而代理机制则是一种更好的解决方案。代理类可以在运行时创建全新的类。这样一个代理类能够实现你指定的接口。具体地,代理类包含以下方法:

  • 指定接口所需要的全部方法。
  • Object 类中定义的全部方法(toStringequals 等)。

不过,不能在运行时为这些方法定义新代码。实际上,必须提供一个调用处理器(invocation handler)。调用处理器是实现了 InvocationHandler 接口的类的对象。这个接口只有一个方法:

1
Object invoke(Object proxy, Method method, Object[] args)

无论何时调用代理对象的方法,都会调用这个调用处理器的 invoke 方法,并提供 Method 对象和原调用的参数。之后,调用处理器必须确定如何处理这个调用。

6.5.2 创建代理对象

要想创建一个代理对象,需要使用 Proxy 类的 newProxyInstance 方法。这个方法有三个参数:

  • 一个类加载器(class loader)。作为 Java 安全模型的一部分,对于平台和应用类、从因特网下载的类等等可以使用不同的类加载器。在这个例子中,我们指定了加载平台和应用类的“系统类加载器”。
  • 一个 Class 对象数组,每个元素对应需要实现的各个接口。
  • 一个调用处理器。

还有两个需要解决的问题。如何定义处理器?另外,对于得到的代理对象能够做些什么?当然,这两个问题的答案取决于我们想要用代理机制解决什么问题。使用代理可能出于很多目的,例如:

  • 将方法调用路由到远程服务器。
  • 将用户界面事件与正在运行的程序中的动作关联起来。
  • 为了调试而跟踪方法调用。

在示例程序中,我们要使用代理和调用处理器跟踪方法调用。我们定义了一个 TraceHandler 包装器类存储一个包装的对象,其 invoke 方法会打印所调用方法的名字和参数,随后调用这个方法,并提供所包装的对象作为隐式参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
System.out.print(target + "." + m.getName() + "(");
if (args != null) {
for (int i = 0; i < args.length; i++) {
if (i > 0) System.out.print(", ");
System.out.print(args[i]);
}
}
System.out.println(")");

// invoke actual method
return m.invoke(target, args);
}
}

可以如下构造一个代理对象,只要调用它的某个方法,就会触发跟踪行为:

1
2
3
4
5
6
7
8
9
10
Object value = ...;

// construct wrapper
var handler = new TraceHandler(value);

// construct proxy for one or more interfaces
Object proxy = Proxy.newProxyInstance(
ClassLoader.getSystemClassLoader(),
new Class[] { Comparable.class },
handler);

现在,只要在 proxy 上调用了某个接口的方法,就会打印这个方法的名字和参数,之后再用 value 调用这个方法。

在程序清单 6-10 所示的程序中,我们使用代理对象跟踪一个二分查找。这里首先在数组中填充整数 1~1000 的代理,然后调用 Arrays 类的 binarySearch 方法在数组中查找一个随机整数。最后,打印出匹配的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var 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.newProxyInstance(...); // proxy for value;
}

// construct a random integer
Integer key = (int) (Math.random() * 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 方法。

注释:在本章前面已经看到,Integer 类实际上实现了 Comparable<Integer>。不过,在运行时,所有的泛型类型都会擦除,会用对应原始 Comparable 类的类对象构造代理。

binarySearch 方法有以下调用:

1
if (elements[i].compareTo(key) < 0) ...

由于数组中填充了代理对象,所以 compareTo 会调用 TraceHandler 类中的 invoke 方法。这个方法会打印方法名和参数,之后在包装的 Integer 对象上调用 compareTo

最后,在示例程序的最后调用:

1
System.out.println(elements[result]);

这个 println 方法调用代理对象的 toString,这个调用也会重定向到调用处理器。

下面是程序运行时完整的跟踪结果:

1
2
3
4
5
6
7
8
588.compareTo(288)
294.compareTo(288)
375.compareTo(288)
312.compareTo(288)
281.compareTo(288)
296.compareTo(288)
288.compareTo(288)
288.toString()

可以看到二分查找算法是如何查找 key 值的,每一步都会将查找区间缩减一半。注意,尽管 toString 方法不属于 Comparable 接口,但这个方法也会被代理。在下一节中会看到,某些 Object 方法总是会被代理。

6.5.3 代理类的特性

需要记住,代理类是在程序运行过程中动态创建的。不过,一旦创建,它们就是常规的类,与虚拟机中的任何其他类没有什么区别。

所有的代理类都扩展 Proxy 类。一个代理类只有一个实例字段——即调用处理器,它在 Proxy 超类中定义。完成代理对象任务所需要的任何额外数据都必须存储在调用处理器中。

所有的代理类都要覆盖 Object 类的 toStringequalshashCode 方法。如同所有代理方法一样,这些方法只是在调用处理器上调用 invokeObject 类中的其他方法(如 clonegetClass)没有重新定义。

没有定义代理类的名字,Oracle 虚拟机中的 Proxy 类会生成以字符串 $Proxy 开头的类名。对于一个特定的类加载器和一组接口,只能有一个代理类。也就是说,如果使用同一个类加载器和接口数组调用两次 newProxyInstance 方法,将得到同一个类的两个对象。也可以利用 getProxyClass 方法获得这个类:

1
Class proxyClass = Proxy.getProxyClass(null, interfaces);

代理类总是 publicfinal。如果代理类实现的所有接口都是 public,这个代理类就不属于任何特定的包;否则,所有非公共的接口都必须属于同一个包,而且代理类也属于这个包。

可以通过调用 Proxy 类的 isProxyClass 方法检测一个特定的 Class 对象是否表示一个代理类。

注释:调用一个目标代理的默认方法会触发调用处理器。要具体调用这个方法,可以使用 InvocationHandler 接口的静态方法 invokeDefault。例如,下面是一个调用处理器,它会调用默认方法,并把抽象方法传递到另一个目标:

1
2
3
4
5
6
InvocationHandler handler = (proxy, method, args) -> {
if (method.isDefault())
return InvocationHandler.invokeDefault(proxy, method, args);
else
return method.invoke(target, args);
}

《Java核心技术卷一》第六章
http://example.com/2025/11/11/《Java核心技术卷一》第六章/
作者
Under1ines
发布于
2025年11月11日
许可协议