杨柳亭

杨柳亭

42. lambda表达式优于匿名类

42. lambda 表达式优于匿名类

在 Java 8 中,添加了函数式接口,lambda 表达式和方法引用,以便更容易地创建函数对象。 Stream API 随着其他语言的修改一同被添加进来,为处理数据元素序列提供类库支持。 在本章中,我们将讨论如何充分利用这些功能。

以往,使用单一抽象方法的接口(或者很少使用的抽象类)被用作函数类型。 它们的实例(称为函数对象)表示函数(functions)或行动(actions)。 自从 JDK 1.1 于 1997 年发布以来,创建函数对象的主要手段就是匿名类(详见第 24 条)。 下面是一段代码片段,按照字符串长度顺序对列表进行排序,使用匿名类创建排序的比较方法(强制排序顺序):

1
2
3
4
5
6
// Anonymous class instance as a function object - obsolete!
Collections.sort(words, new Comparator<String>() {
public int compare(String s1, String s2) {
return Integer.compare(s1.length(), s2.length());
}
});

匿名类适用于需要函数对象的经典面向对象设计模式,特别是策略模式[Gamma95]。 比较器接口表示排序的抽象策略; 上面的匿名类是排序字符串的具体策略。 然而,匿名类的冗长,使得 Java 中的函数式编程成为一种不吸引人的设想。

在 Java 8 中,语言形式化了这样的概念,即使用单个抽象方法的接口是特别的,应该得到特别的对待。 这些接口现在称为函数式接口,并且该语言允许你使用 lambda 表达式或简称 lambdas 来创建这些接口的实例。 Lambdas 在功能上与匿名类相似,但更为简洁。 下面的代码使用 lambdas 替换上面的匿名类。 样板不见了,行为清晰明了:

1
2
3
// Lambda expression as function object (replaces anonymous class)
Collections.sort(words,
(s1, s2) -> Integer.compare(s1.length(), s2.length()));

请注意,代码中不存在 lambdaComparator <String>),其参数(s1 和 s2,都是 String 类型)及其返回值(int)的类型。 编译器使用称为类型推断的过程从上下文中推导出这些类型。 在某些情况下,编译器将无法确定类型,必须指定它们。 类型推断的规则很复杂:他们在 JLS 中占据了整个章节[JLS,18]。 很少有程序员详细了解这些规则,但没关系。 除非它们的存在使你的程序更清晰,否则省略所有 lambda 参数的类型。 如果编译器生成一个错误,告诉你它不能推断出 lambda 参数的类型,那么指定它。 有时你可能不得不强制转换返回值或整个 lambda 表达式,但这很少见。

关于类型推断需要注意一点。 条目 26 告诉你不要使用原始类型,条目 29 告诉你偏好泛型类型,条目 30 告诉你偏向泛型方法。 当使用 lambda 表达式时,这个建议是非常重要的,因为编译器获得了大部分允许它从泛型进行类型推断的类型信息。 如果你没有提供这些信息,编译器将无法进行类型推断,你必须在 lambdas 中手动指定类型,这将大大增加它们的冗余度。 举例来说,如果变量被声明为原始类型 List 而不是参数化类型 List<String>,则上面的代码片段将不会编译。

顺便提一句,如果使用比较器构造方法代替 lambda,则代码中的比较器可以变得更加简洁(详见第 14 和 43 条):

1
Collections.sort(words, comparingInt(String::length));

实际上,通过利用添加到 Java 8 中的 List 接口的 sort 方法,可以使片段变得更简短:

1
words.sort(comparingInt(String::length));

lambdas 添加到该语言中,使得使用函数对象在以前没有意义的地方非常实用。例如,考虑条目 34 中的 Operation 枚举类型。由于每个枚举都需要不同的应用程序行为,所以我们使用了特定于常量的类主体,并在每个枚举常量中重写了 apply 方法。为了刷新你的记忆,下面是之前的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Enum type with constant-specific class bodies & data
public enum Operation {
PLUS("+") {
public double apply(double x, double y) { return x + y; }
},

MINUS("-") {
public double apply(double x, double y) { return x - y; }
},

TIMES("*") {
public double apply(double x, double y) { return x * y; }
},

DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};

private final String symbol;

Operation(String symbol) { this.symbol = symbol; }

@Override
public String toString() { return symbol; }

public abstract double apply(double x, double y);
}

第 34 条目说,枚举实例属性比常量特定的类主体更可取。 Lambdas 可以很容易地使用前者而不是后者来实现常量特定的行为。 仅仅将实现每个枚举常量行为的 lambda 传递给它的构造方法。 构造方法将 lambda 存储在实例属性中,apply 方法将调用转发给 lambda。 由此产生的代码比原始版本更简单,更清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum Operation {
PLUS ("+", (x, y) -> x + y),
MINUS ("-", (x, y) -> x - y),
TIMES ("*", (x, y) -> x * y),
DIVIDE("/", (x, y) -> x / y);

private final String symbol;
private final DoubleBinaryOperator op;

Operation(String symbol, DoubleBinaryOperator op) {
this.symbol = symbol;
this.op = op;
}

@Override
public String toString() { return symbol; }

public double apply(double x, double y) {
return op.applyAsDouble(x, y);
}
}

请注意,我们使用表示枚举常量行为的 lambdasDoubleBinaryOperator 接口。 这是 java.util.function 中许多预定义的函数接口之一(详见第 44 条)。 它表示一个函数,它接受两个 double 类型参数并返回 double 类型的结果。

看看基于 lambdaOperation 枚举,你可能会认为常量特定的方法体已经失去了它们的用处,但事实并非如此。 与方法和类不同,lambda 没有名称和文档; 如果计算不是自解释的,或者超过几行,则不要将其放入 lambda 表达式中。 一行代码对于 lambda 说是理想的,三行代码是合理的最大值。 如果违反这一规定,可能会严重损害程序的可读性。 如果一个 lambda 很长或很难阅读,要么找到一种方法来简化它或重构你的程序来消除它。 此外,传递给枚举构造方法的参数在静态上下文中进行评估。 因此,枚举构造方法中的 lambda 表达式不能访问枚举的实例成员。 如果枚举类型具有难以理解的常量特定行为,无法在几行内实现,或者需要访问实例属性或方法,那么常量特定的类主体仍然是行之有效的方法。

同样,你可能会认为匿名类在 lambda 时代已经过时了。 这更接近事实,但有些事情你可以用匿名类来做,而却不能用 lambdas 做。 Lambda 仅限于函数式接口。 如果你想创建一个抽象类的实例,你可以使用匿名类来实现,但不能使用 lambda。 同样,你可以使用匿名类来创建具有多个抽象方法的接口实例。 最后,lambda 不能获得对自身的引用。 在 lambda 中,this 关键字引用封闭实例,这通常是你想要的。 在匿名类中,this 关键字引用匿名类实例。 如果你需要从其内部访问函数对象,则必须使用匿名类。

Lambdas 与匿名类共享无法可靠地序列化和反序列化实现的属性。因此,应该很少 (如果有的话) 序列化一个 lambda(或一个匿名类实例)。 如果有一个想要进行序列化的函数对象,比如一个 Comparator,那么使用一个私有静态嵌套类的实例(详见第 24 条)。

综上所述,从 Java 8 开始,lambda 是迄今为止表示小函数对象的最佳方式。 除非必须创建非函数式接口类型的实例,否则不要使用匿名类作为函数对象。 另外,请记住,lambda 表达式使代表小函数对象变得如此简单,以至于它为功能性编程技术打开了一扇门,这些技术在 Java 中以前并不实用。

57. 最小化局部变量的作用域

57. 最小化局部变量的作用域

这条目在性质上类似于条目 15,即“最小化类和成员的可访问性”。通过最小化局部变量的作用域,可以提高代码的可读性和可维护性,并降低出错的可能性。

较早的编程语言(如 C)要求必须在代码块的头部声明局部变量,并且一些程序员继续习惯这样做。 这是一个值得改进的习惯。 作为提醒,Java 允许你在任何合法的语句的地方声明变量(as does C, since C99)。

用于最小化局部变量作用域的最强大的技术是再首次使用的地方声明它。 如果变量在使用之前被声明,那就变得更加混乱—— 这也会对试图理解程序的读者来讲,又增加了一件分散他们注意力的事情。 到使用该变量时,读者可能不记得变量的类型或初始值。

过早地声明局部变量可能导致其作用域不仅过早开始而且结束太晚。 局部变量的作用域从声明它的位置延伸到封闭块的末尾。 如果变量在使用它的封闭块之外声明,则在程序退出该封闭块后它仍然可见。如果在其预定用途区域之前或之后意外使用变量,则后果可能是灾难性的。

几乎每个局部变量声明都应该包含一个初始化器。如果还没有足够的信息来合理地初始化一个变量,那么应该推迟声明,直到认为可以这样做。这个规则的一个例外是 try-catch 语句。如果一个变量被初始化为一个表达式,该表达式的计算结果可以抛出一个已检查的异常,那么该变量必须在 try 块中初始化(除非所包含的方法可以传播异常)。如果该值必须在 try 块之外使用,那么它必须在 try 块之前声明,此时它还不能被「合理地初始化」。例如,参照条目 65 中的示例。

循环提供了一个特殊的机会来最小化变量的作用域。传统形式的 for 循环和 for-each 形式都允许声明循环变量,将其作用域限制在需要它们的确切区域。 (该区域由循环体和 for 关键字与正文之间的括号中的代码组成)。因此,如果循环终止后不需要循环变量的内容,那么优先选择 for 循环而不是 while 循环

例如,下面是遍历集合的首选方式(详见第 58 条):

1
2
3
4
// Preferred idiom for iterating over a collection or array
for (Element e : c) {
... // Do Something with e
}

如果需要访问迭代器,也许是为了调用它的 remove 方法,首选的习惯用法,使用传统的 for 循环代替 for-each 循环:

1
2
3
4
5
// Idiom for iterating when you need the iterator
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
Element e = i.next();
... // Do something with e and i
}

要了解为什么这些 for 循环优于 while 循环,请考虑以下代码片段,其中包含两个 while 循环和一个 bug:

1
2
3
4
5
6
7
8
9
Iterator<Element> i = c.iterator();
while (i.hasNext()) {
doSomething(i.next());
}
...
Iterator<Element> i2 = c2.iterator();
while (i.hasNext()) { // BUG!
doSomethingElse(i2.next());
}

第二个循环包含一个复制粘贴错误:它初始化一个新的循环变量 i2,但是使用旧的变量 i,不幸的是,它仍在范围内。 生成的代码编译时没有错误,并且在不抛出异常的情况下运行,但它做错了。 第二个循环不是在 c2 上迭代,而是立即终止,给出了 c2 为空的错误印象。 由于程序无声地出错,因此错误可能会长时间无法被检测到。

如果将类似的复制粘贴错误与 for 循环(for-each 循环或传统循环)结合使用,则生成的代码甚至无法编译。第一个循环中的元素(或迭代器)变量不在第二个循环中的作用域中。下面是它与传统 for 循环的示例:

1
2
3
4
5
6
7
8
9
10
11
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
Element e = i.next();
... // Do something with e and i
}
...

// Compile-time error - cannot find symbol i
for (Iterator<Element> i2 = c2.iterator(); i.hasNext(); ) {
Element e2 = i2.next();
... // Do something with e2 and i2
}

此外,如果使用 for 循环,那么发送这种复制粘贴错误的可能性要小得多,因为没有必要在两个循环中使用不同的变量名。 循环是完全独立的,因此重用元素(或迭代器)变量名称没有坏处。 事实上,这样做通常很流行。

for 循环比 while 循环还有一个优点:它更短,增强了可读性。

下面是另一种循环习惯用法,它最小化了局部变量的作用域:

1
2
3
for (int i = 0, n = expensiveComputation(); i < n; i++) {
... // Do something with i;
}

关于这个做法需要注意的重要一点是,它有两个循环变量,i 和 n,它们都具有完全相同的作用域。第二个变量 n 用于存储第一个变量的限定值,从而避免了每次迭代中冗余计算的代价。作为一个规则,如果循环测试涉及一个方法调用,并且保证在每次迭代中返回相同的结果,那么应该使用这种用法。

最小化局部变量作用域的最终技术是保持方法小而集中。 如果在同一方法中组合两个行为(activities),则与一个行为相关的局部变量可能会位于执行另一个行为的代码范围内。 为了防止这种情况发生,只需将方法分为两个:每个行为对应一个方法。

90. 考虑用序列化代理代替序列化实例

90. 考虑用序列化代理代替序列化实例

正如 85 条和第 86 条提到的,以及本章一直在讨论的,决定实现 Serializable 接口,会增加出错和出现安全问题的可能性,因为它允许利用语言之外的机制来创建实例,而不是使用普通的构造器。然而,有一种方法可以极大的减少这些风险。就是序列化代理模式(seralization proxy pattern)。

序列化代理模式相当简单。首先,为可序列化的类设计一个私有的静态嵌套类,精确地表示外围类的逻辑状态。这个嵌套类被称为序列化代理(seralization proxy),它应该有一个单独的构造器,其参数类型就是那个外围类。这个构造器只是从它的参数中复制数据:它不需要进行任何一致性检验或者保护性拷贝。从设计的角度看,序列化代理的默认序列化形式是外围类最好的序列化形式。外围类及其序列代理都必须声明实现 Serializable 接口。

例如,以第 50 条中编写不可变的 Period 类为例,它在第 88 条中被作为可序列化的。以下是一个类的序列化代理。Period 类是如此简单,以致于它的序列化代理有着与类完全相同的字段。

1
2
3
4
5
6
7
8
9
10
11
// Serialization proxy for Period class
private static class SerializationProxy implements Serializable {
private final Date start;
private final Date end;
SerializationProxy(Period p) {
this.start = p.start;
this.end = p.end;
}
private static final long serialVersionUID =
234098243823485285L; // Any number will do (Item 87)
}

接下来,将下面的 writeReplace 方法添加到外围类中。通过序列化代理,这个方法可以被逐字的复制到任何类中。

1
2
3
4
// writeReplace method for the serialization proxy pattern
private Object writeReplace() {
return new SerializationProxy(this);
}

这个方法的存在就是导致系统产生一个 SerializationProxy 实例,代替外围类的实例。换句话说 writeReplace 方法在序列化之前,将外围类的实例转变成了它的序列化代理。

有了 writeReplace 方法之后,序列化系统永远不会产生外围类的序列化实例,但是攻击者有可能伪造企图违反该类约束条件的示例。为了防止此类攻击,只需要在外围类中添加如下 readObject 方法。

1
2
3
4
5
// readObject method for the serialization proxy pattern
private void readObject(ObjectInputStream stream)
throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}

最后在 SerializationProxy 类中提供一个 readResolve 方法,他返回一个逻辑上等价的外围类的实例。这个方法的出现,导致序列化系统在反序列化的时候将序列化代理转为外围类的实例。

这个 readResolve 方法仅仅利用它的公有 API 创建外围类的一个实例,这正是该模式的魅力所在它极大的消除了序列化机制中语言之外的特征,因为反序列化实例是利用与任何其他实例相同的构造器、静态工厂和方法而创建的。这样你就不必单独确保被反序列化的实例一定要遵守类的约束条件。如果该类的静态工厂或者构造器建立了这些约束条件,并且它的实例方法保持着这些约束条件,你就可以确信序列化也确保着这些约束条件。

以下是上述的 Period.SerializationProxyreadResolve 方法:

1
2
3
4
// readResolve method for Period.SerializationProxy
private Object readResolve() {
return new Period(start, end); // Uses public constructor
}

正如保护性拷贝方法一样(详见 88 条),序列化代理方式可以阻止伪字节流的攻击(详见 88 条)以及内部字段的盗用攻击(详见 88 条)。与前两种方法不同,这种方法允许 Period 类的字段为 final,为了确保 Period 类是真正不可变的(详见 17 条),这一点非常重要。与前两种方法不同的还有,这种方法不需要太费心思。你不必知道哪些字段可能受到狡猾的序列化攻击的威胁,你也不必显式的执行有效性检查,作为反序列化的一部分。

还有另外一种方法,使用这种方法时,序列化代理模式的功能比保护性拷贝的更加强大。序列化代理模式允许反序列化实例有着与原始序列化实例不同的类。你可能认为这在实际应用中没有什么作用,其实不然。

EnumSet 的情况为例(详见 36 条)。这个类没有公有的构造器,只有静态工厂。从客户端的角度来看,他们返回 EnumSet 实例,但是在 OpenJDK 的实现,它们返回的是两种子类之一,具体取决于底层枚举类型的大小。如果底层的枚举类型有 64 个或者少于 64 个的元素,静态工厂就返回一个 RegularEnumSet;它们就返回一个 JunmboEnumSet

现在考虑这种情况:如果序列化一个枚举类型,它的枚举有 60 个元素,然后给这个枚举类型再增加 5 个元素,之后反序列化这个枚举集合。当它被序列化的时候,是一个 RegularEnumSet 实例,但是它一旦被反序列化,他就变成了 JunmboEnumSet 实例。实际发生的情况也正是如此,因为 EnumSet 使用序列化代理模式如果你感兴趣,可以看看如下的 EnumSet 序列化代理,它实际上就是这么简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// EnumSet's serialization proxy
private static class SerializationProxy<E extends Enum<E>>
implements Serializable {
private static final long serialVersionUID = 362491234563181265L;

// The element type of this enum set.
private final Class<E> elementType;

// The elements contained in this enum set.
private final Enum<?>[] elements;

SerializationProxy(EnumSet<E> set) {
elementType = set.elementType;
elements = set.toArray(new Enum<?>[0]);
}

private Object readResolve() {
EnumSet<E> result = EnumSet.noneOf(elementType);

for (Enum<?> e : elements)
result.add((E) e);

return result;
}
}

序列化代理模式有两个局限性。它不能与可以被客户端拓展的类兼容(详见 19 条)。它也不能与对象图中包含循环的某些类兼容:如果你企图从一个对象的序列化代理的 readResovle 方法内部调用这个对象的方法,就会得到一个 ClassCastException 异常,因为你还没有这个对象,只有它的序列化代理。

最后一点,序列化代理模式所增强的功能和安全性不是没有代价。在我的机器上,通过序列化代理来序列化和反序列化 Period 实例的开销,比使用保护性拷贝增加了 14%。

总而言之,当你发现必须在一个不能被客户端拓展的类上面编写 readObject 或者 writeObject 方法时,就应该考虑使用序列化代理模式。想要稳健的将带有重要约束条件的对象序列化时,这种模式是最容易的方法。

69. 只针对异常的情况下才使用异常

69. 只针对异常的情况下才使用异常

假如你某一天不走运的话,可能遇到如下代码:

1
2
3
4
5
6
7
/* Horrible abuse of exceptions. Don't ever do this! */
try {
int i = 0;
while ( true )
range[i++].climb();
} catch ( ArrayIndexOutOfBoundsException e ) {
}

这段代码是干什么的?看起来根本不明显,这正是它没有真正被使用的原因(详见 67 条)。事实证明,作为一个要对数组元素进行遍历的实现方式,它的构想是十分拙劣的。当这个循环企图访问数组边界之外的第一个数组元素的时候,使用 try-catch 并且忽略 ArrayIndexOutOfBoundsException 异常的手段来达到终止无限循环的目的。假定它与数组循环的标准模式是等价的,对于它的标准模式每个 Java 程序员都可以一眼辨认出来:

1
2
for ( Mountain m : range )
m.climb();

那么为什么有人会企图使用基于异常的循环,而不是使用行之有效的模式呢?这是他们误以为可以使用 Java 的错误判断机制来提高程序性能,因为 VM 对每次数组访问都要检查越界情况,所以他们认为正常的循环终止测试被编译器隐藏了,但是在 for-each 中仍然可见,这是多余的并且应当避免。这种想法有三个错误:

  • 因为异常设计的初衷适用于不正常的情形,所有几乎没有 JVM 实现试图对他们进行优化,使它们与显式的测试一样快。
  • 把代码放在 try-catch 块中反而阻止了现代 JVM 实现本可能执行的某些特定优化。
  • 对数据进行遍历的标准模式并不会导致冗余的检查。有些 JVM 实现会将它们优化掉。

实际上基于异常的模式比标准模式要慢得多。在我本地的机器上,对于一个有 100 个元素的数组进行遍历,标准模式比基于异常的模式快了 2 倍。

基于异常的循环模式不仅模糊了代码的意图,降低了它的性能,而且它还不能保证正常工作!如果出现了不相关的 bug,这个模式会悄悄的消失从而掩盖了这个 Bug,极大地增加了调试过程的复杂性。假设循环体的计算过程中调用了一个方法,这个方法执行了对某个不相关数组的越界访问。如果使用合理的循环模式,这个 Bug 会产生未被捕捉的异常,从而导致线程立即结束,并产生完整的堆栈轨迹。如果使用这个被误导的基于异常的循环模式,与这个 Bug 相关的异常将会被捕捉到,并且被错误的解释为正常的循环终止条件。

这个例子的教训很简单:顾名思义,异常应该只用于异常的情况下;他们永远不应该用于正常的程序控制流程。 一般的,应该优先使用标准的、容易理解的模式,而不是那些声称可以提供更好性能的、弄巧成拙的方法。即使真的能够改进性能,面对平台的不断改进,这种模型的性能优势也不可能一直保持。然而这种过度聪明的模式带来的微妙 Bug 和维护的痛苦将依旧存在。

这条原则对于 API 设计也有启发**。设计良好的 API 不应该强迫它的客户端为了正常的控制流程而使用异常。**如果类中具有「状态相关」(state-dependent)的方法,即只有在特定的不可预知的条件下才可以被调用的方法,这个类往往也应该具有一个单独的「状态测试」(state-testing)方法,即表明是否可以调用这个状态相关的方法。比如 Iterator 接口含有状态相关的 next 方法,以及相应的状态测试方法 hasNext。这使得利用传统的 for 循环(以及 for-each 循环,在内部使用了 hasNext 方法)对集合进行迭代的标准模式成为可能。

1
2
3
4
for ( Iterator<Foo> i = collection.iterator(); i.hasNext(); ){
Foo foo = i.next();
...
}

如果 Iterator 缺少 hasNext 方法,客户端将被迫改用下面的做法:

1
2
3
4
5
6
7
8
9
10
/* Do not use this hideous code for iteration over a collection! */
try {
Iterator<Foo> i = collection.iterator();
while ( true )
{
Foo foo = i.next();
...
}
} catch ( NoSuchElementException e ) {
}

这应该非常类似于本条目刚开始时对数据进行迭代的例子。除了代码繁琐令人误解之外,这个基于异常的模式可能执行起来也比标准模式更差,并且还可能掩盖系统中其他不相关部分的 Bug。

另外一种提供单独状态测试的做法是,如果「状态相关」方法无法执行想要的计算,就可以让它返回一个零长度的 optional 值(详见第 55 条),或者返回一个可被识别的返回值,比如 null。

对于「状态测试方法」和「optional 返回值或者可识别的返回值」这两种做法,有些指导原则可以帮助你在两者之间做出选择。如果对象将在缺少外部同步的情况下被并发访问,或者可被外界改变状态,就必须使用「optional 返回值或者可识别的返回值」,因为在调用「状态测试」方法和调用对应的「状态相关」方法的时间间隔之中,对象的状态有可能发生变化。如果单独的「状态测试」方法必须重复「状态相关」方法的工作,从性能的角度考虑,就必须使用可被识别的返回值。如果其他方面都是等同的,那么「状态测试」方法则优于可被识别的返回值。他提供了相对更高的可读性,对于使用不当的情形可能更加易于检测和改正:如果忘了去调用状态测试方法,状态相关的方法就会抛出异常,使得这个 Bug 变得很明显;如果忘了去检查可识别的返回值,这个 Bug 就很难被发现。optional 返回值不会有这方面的问题。

总而言之,异常是为了在异常情况下被设计和使用的。不要将它们勇于普通的控制流程,也不要编写迫使它们这么做的 API。

06. 避免创建不必要的对象

6. 避免创建不必要的对象

在每次需要时重用一个对象而不是创建一个新的相同功能对象通常是恰当的。重用可以更快更流行。如果对象是不可变的(详见第 17 条),它总是可以被重用。

作为一个不应该这样做的极端例子,请考虑以下语句:

1
String s = new String("bikini");  // DON'T DO THIS!

语句每次执行时都会创建一个新的 String 实例,而这些对象的创建都不是必需的。String 构造方法 ("bikini") 的参数本身就是一个 bikini 实例,它与构造方法创建的所有对象的功能相同。如果这种用法发生在循环中,或者在频繁调用的方法中,就会毫无必要地创建数百万个 String 实例。

改进后的版本如下:

1
String s = "bikini";

该版本使用单个 String 实例,而不是每次执行时创建一个新实例。此外,它可以保证对象运行在同一虚拟机上的任何其他代码重用,而这些代码恰好包含相同的字符串字面量[JLS, 3.10.5]

通过使用静态工厂方法(详见第 1 条)和构造器,可以避免创建不需要的对象。例如,工厂方法 Boolean.valueOf(String) 比构造方法 Boolean(String) 更可取,后者在 Java 9 中被弃用。构造方法每次调用时都必须创建一个新对象,而工厂方法永远不需要这样做,在实践中也不需要。除了重用不可变对象,如果知道它们不会被修改,还可以重用可变对象。

一些对象的创建比其他对象的创建要昂贵得多。 如果要重复使用这样一个「昂贵的对象」,建议将其缓存起来以便重复使用。 不幸的是,当创建这样一个对象时并不总是很直观明显的。 假设你想写一个方法来确定一个字符串是否是一个有效的罗马数字。 以下是使用正则表达式完成此操作时最简单方法:

1
2
3
4
5
// Performance can be greatly improved!
static boolean isRomanNumeral(String s) {
return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

这个实现的问题在于它依赖于 String.matches 方法。 虽然 String.matches 是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能临界的情况下重复使用。 问题是它在内部为正则表达式创建一个 Pattern 实例,并且只使用它一次,之后它就有资格进行垃圾收集。 创建 Pattern 实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。

为了提高性能,作为类初始化的一部分,将正则表达式显式编译为一个 Pattern 实例(不可变),缓存它,并在 isRomanNumeral 方法的每个调用中重复使用相同的实例:

1
2
3
4
5
6
7
8
9
10
// Reusing expensive object for improved performance
public class RomanNumerals {
private static final Pattern ROMAN = Pattern.compile(
"^(?=.)M*(C[MD]|D?C{0,3})"
+ "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}

如果经常调用,isRomanNumeral 的改进版本的性能会显著提升。 在我的机器上,原始版本在输入 8 个字符的字符串上需要 1.1 微秒,而改进的版本则需要 0.17 微秒,速度提高了 6.5 倍。 性能上不仅有所改善,而且更明确清晰了。 为不可见的 Pattern 实例创建静态 final 修饰的属性,并允许给它一个名字,这个名字比正则表达式本身更具可读性。

如果包含 isRomanNumeral 方法的改进版本的类被初始化,但该方法从未被调用,则 ROMAN 属性则没必要初始化。 在第一次调用 isRomanNumeral 方法时,可以通过延迟初始化( lazily initializing)属性(详见第 83 条)来排除初始化,但一般不建议这样做。 延迟初始化常常会导致实现复杂化,而性能没有可衡量的改进(详见第 67 条)。

当一个对象是不可变的时,很明显它可以被安全地重用,但是在其他情况下,它远没有那么明显,甚至是违反直觉的。考虑适配器(adapters)的情况[Gamma95],也称为视图(views)。一个适配器是一个对象,它委托一个支持对象(backing object),提供一个可替代的接口。由于适配器没有超出其支持对象的状态,因此不需要为给定对象创建多个给定适配器的实例。

例如,Map 接口的 keySet 方法返回 Map 对象的 Set 视图,包含 Map 中的所有 key。 天真地说,似乎每次调用 keySet 都必须创建一个新的 Set 实例,但是对给定 Map 对象的 keySet 的每次调用都返回相同的 Set 实例。 尽管返回的 Set 实例通常是可变的,但是所有返回的对象在功能上都是相同的:当其中一个返回的对象发生变化时,所有其他对象也都变化,因为它们全部由相同的 Map 实例支持。 虽然创建 keySet 视图对象的多个实例基本上是无害的,但这是没有必要的,也没有任何好处。

另一种创建不必要的对象的方法是自动装箱(auto boxing),它允许程序员混用基本类型和包装的基本类型,根据需要自动装箱和拆箱。 自动装箱模糊不清,但不会消除基本类型和装箱基本类型之间的区别。 有微妙的语义区别和不那么细微的性能差异(详见第 61 条)。 考虑下面的方法,它计算所有正整数的总和。 要做到这一点,程序必须使用 long 类型,因为 int 类型不足以保存所有正整数的总和:

1
2
3
4
5
6
7
// Hideously slow! Can you spot the object creation?
private static long sum() {
Long sum = 0L;
for (long i = 0; i <= Integer.MAX_VALUE; i++)
sum += i;
return sum;
}

这个程序的结果是正确的,但由于写错了一个字符,运行的结果要比实际慢很多。变量 sum 被声明成了 Long 而不是 long,这意味着程序构造了大约 231 不必要的 Long 实例(大约每次往 Long 类型的 sum 变量中增加一个 long 类型构造的实例),把 sum 变量的类型由 Long 改为 long,在我的机器上运行时间从 6.3 秒降低到 0.59 秒。这个教训很明显:优先使用基本类型而不是装箱的基本类型,也要注意无意识的自动装箱。

这个条目不应该被误解为暗示对象创建是昂贵的,应该避免创建对象。 相反,使用构造方法创建和回收小的对象是非常廉价,构造方法只会做很少的显示工作,尤其是在现代 JVM 实现上。 创建额外的对象以增强程序的清晰度,简单性或功能性通常是件好事。

相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免对象创建是一个坏主意。对象池的典型例子就是数据库连接。建立连接的成本非常高,因此重用这些对象是有意义的。但是,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代 JVM 实现具有高度优化的垃圾收集器,它们在轻量级对象上轻松胜过此类对象池。

这个条目的对应点是针对条目 50 的防御性复制(defensive copying)。 目前的条目说:「当你应该重用一个现有的对象时,不要创建一个新的对象」,而条目 50 说:「不要重复使用现有的对象,当你应该创建一个新的对象时。」请注意,重用防御性复制所要求的对象所付出的代价,要远远大于不必要地创建重复的对象。 未能在需要的情况下防御性复制会导致潜在的错误和安全漏洞;而不必要地创建对象只会影响程序的风格和性能。

08. 避免使用Finalizer和Cleaner机制

8. 避免使用 Finalizer 和 Cleaner 机制

Finalizer 机制是不可预知的,往往是危险的,而且通常是不必要的。 它们的使用会导致不稳定的行为,糟糕的性能和移植性问题。 Finalizer 机制有一些特殊的用途,我们稍后会在这个条目中介绍,但是通常应该避免它们。 从 Java 9 开始,Finalizer 机制已被弃用,但仍被 Java 类库所使用。 Java 9 中 Cleaner 机制代替了 Finalizer 机制。 Cleaner 机制不如 Finalizer 机制那样危险,但仍然是不可预测,运行缓慢并且通常是不必要的。

提醒 C++程序员不要把 Java 中的 Finalizer 或 Cleaner 机制当成的 C++ 析构函数的等价物。 在 C++ 中,析构函数是回收对象相关资源的正常方式,是与构造方法相对应的。 在 Java 中,当一个对象变得不可达时,垃圾收集器回收与对象相关联的存储空间,不需要开发人员做额外的工作。 C++ 析构函数也被用来回收其他非内存资源。 在 Java 中,try-with-resources 或 try-finally 块用于此目的(详见第 9 条)。

Finalizer 和 Cleaner 机制的一个缺点是不能保证他们能够及时执行[JLS,12.6]。 在一个对象变得无法访问时,到 Finalizer 和 Cleaner 机制开始运行时,这期间的时间是任意长的。 这意味着你永远不应该 Finalizer 和 Cleaner 机制做任何时间敏感(time-critical)的事情。 例如,依赖于 Finalizer 和 Cleaner 机制来关闭文件是严重的错误,因为打开的文件描述符是有限的资源。 如果由于系统迟迟没有运行 Finalizer 和 Cleaner 机制而导致许多文件被打开,程序可能会失败,因为它不能再打开文件了。

及时执行 Finalizer 和 Cleaner 机制是垃圾收集算法的一个功能,这种算法在不同的实现中有很大的不同。程序的行为依赖于 Finalizer 和 Cleaner 机制的及时执行,其行为也可能大不不同。 这样的程序完全可以在你测试的 JVM 上完美运行,然而在你最重要的客户的机器上可能运行就会失败。

延迟终结(finalization)不只是一个理论问题。为一个类提供一个 Finalizer 机制可以任意拖延它的实例的回收。一位同事调试了一个长时间运行的 GUI 应用程序,这个应用程序正在被一个 OutOfMemoryError 错误神秘地死掉。分析显示,在它死亡的时候,应用程序的 Finalizer 机制队列上有成千上万的图形对象正在等待被终结和回收。不幸的是,Finalizer 机制线程的运行优先级低于其他应用程序线程,所以对象被回收的速度低于进入队列的速度。语言规范并不保证哪个线程执行 Finalizer 机制,因此除了避免使用 Finalizer 机制之外,没有轻便的方法来防止这类问题。在这方面, Cleaner 机制比 Finalizer 机制要好一些,因为 Java 类的创建者可以控制自己 cleaner 机制的线程,但 cleaner 机制仍然在后台运行,在垃圾回收器的控制下运行,但不能保证及时清理。

Java 规范不能保证 Finalizer 和 Cleaner 机制能及时运行;它甚至不能能保证它们是否会运行。当一个程序结束后,一些不可达对象上的 Finalizer 和 Cleaner 机制仍然没有运行。因此,不应该依赖于 Finalizer 和 Cleaner 机制来更新持久化状态。例如,依赖于 Finalizer 和 Cleaner 机制来释放对共享资源(如数据库)的持久锁,这是一个使整个分布式系统陷入停滞的好方法。

不要相信 System.gcSystem.runFinalization 方法。 他们可能会增加 Finalizer 和 Cleaner 机制被执行的几率,但不能保证一定会执行。 曾经声称做出这种保证的两个方法:System.runFinalizersOnExit 和它的孪生兄弟 Runtime.runFinalizersOnExit,包含致命的缺陷,并已被弃用了几十年[ThreadStop]。

Finalizer 机制的另一个问题是在执行 Finalizer 机制过程中,未捕获的异常会被忽略,并且该对象的 Finalizer 机制也会终止 [JLS, 12.6]。未捕获的异常会使其他对象陷入一种损坏的状态(corrupt state)。如果另一个线程试图使用这样一个损坏的对象,可能会导致任意不确定的行为。通常情况下,未捕获的异常将终止线程并打印堆栈跟踪( stacktrace),但如果发生在 Finalizer 机制中,则不会发出警告。Cleaner 机制没有这个问题,因为使用 Cleaner 机制的类库可以控制其线程。

使用 finalizer 和 cleaner 机制会导致严重的性能损失。 在我的机器上,创建一个简单的 AutoCloseable 对象,使用 try-with-resources 关闭它,并让垃圾回收器回收它的时间大约是 12 纳秒。 使用 finalizer 机制,而时间增加到 550 纳秒。 换句话说,使用 finalizer 机制创建和销毁对象的速度要慢 50 倍。 这主要是因为 finalizer 机制会阻碍有效的垃圾收集。 如果使用它们来清理类的所有实例(在我的机器上的每个实例大约是 500 纳秒),那么 cleaner 机制的速度与 finalizer 机制的速度相当,但是如果仅将它们用作安全网(safety net),则 cleaner 机制要快得多,如下所述。 在这种环境下,创建,清理和销毁一个对象在我的机器上需要大约 66 纳秒,这意味着如果你不使用安全网的话,需要支付 5 倍(而不是 50 倍)的保险。

finalizer 机制有一个严重的安全问题:它们会打开你的类来进行 finalizer 机制攻击。finalizer 机制攻击的想法很简单:如果一个异常是从构造方法或它的序列化中抛出的——readObjectreadResolve 方法 (第 12 章)——恶意子类的 finalizer 机制可以运行在本应该「中途夭折(died on the vine)」的部分构造对象上。finalizer 机制可以在静态字属性记录对对象的引用,防止其被垃圾收集。一旦记录了有缺陷的对象,就可以简单地调用该对象上的任意方法,而这些方法本来就不应该允许存在。从构造方法中抛出异常应该足以防止对象出现;而在 finalizer 机制存在下,则不是。这样的攻击会带来可怕的后果。Final 类不受 finalizer 机制攻击的影响,因为没有人可以编写一个 final 类的恶意子类。为了保护非 final 类不受 finalizer 机制攻击,编写一个 final 的 finalize 方法,它什么都不做。

那么,你应该怎样做呢?为对象封装需要结束的资源(如文件或线程),而不是为该类编写 Finalizer 和 Cleaner 机制?让你的类实现 AutoCloseable 接口即可,并要求客户在在不再需要时调用每个实例 close 方法,通常使用 try-with-resources 确保终止,即使面对有异常抛出情况(详见第 9 条)。一个值得一提的细节是实例必须跟踪是否已经关闭:close 方法必须记录在对象里不再有效的属性,其他方法必须检查该属性,如果在对象关闭后调用它们,则抛出 IllegalStateException 异常。

那么,Finalizer 和 Cleaner 机制有什么好处呢?它们可能有两个合法用途。一个是作为一个安全网(safety net),以防资源的拥有者忽略了它的 close 方法。虽然不能保证 Finalizer 和 Cleaner 机制会迅速运行 (或者根本就没有运行),最好是把资源释放晚点出来,也要好过客户端没有这样做。如果你正在考虑编写这样的安全网 Finalizer 机制,请仔细考虑一下这样保护是否值得付出对应的代价。一些 Java 库类,如 FileInputStreamFileOutputStreamThreadPoolExecutorjava.sql.Connection,都有作为安全网的 Finalizer 机制。

第二种合理使用 Cleaner 机制的方法与本地对等类(native peers)有关。本地对等类是一个由普通对象委托的本地 (非 Java) 对象。由于本地对等类不是普通的 Java 对象,所以垃圾收集器并不知道它,当它的 Java 对等对象被回收时,本地对等类也不会回收。假设性能是可以接受的,并且本地对等类没有关键的资源,那么 Finalizer 和 Cleaner 机制可能是这项任务的合适的工具。但如果性能是不可接受的,或者本地对等类持有必须迅速回收的资源,那么类应该有一个 close 方法,正如前面所述。

Cleaner 机制使用起来有点棘手。下面是演示该功能的一个简单的 Room 类。假设 Room 对象必须在被回收前清理干净。Room 类实现 AutoCloseable 接口;它的自动清理安全网使用的是一个 Cleaner 机制,这仅仅是一个实现细节。与 Finalizer 机制不同,Cleaner 机制不污染一个类的公共 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// An autocloseable class using a cleaner as a safety net
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();

// Resource that requires cleaning. Must not refer to Room!
private static class State implements Runnable {
int numJunkPiles; // Number of junk piles in this room

State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}

// Invoked by close method or cleaner
@Override
public void run() {
System.out.println("Cleaning room");
numJunkPiles = 0;
}
}

// The state of this room, shared with our cleanable
private final State state;

// Our cleanable. Cleans the room when it’s eligible for gc
private final Cleaner.Cleanable cleanable;

public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}

@Override
public void close() {
cleanable.clean();
}
}

静态内部 State 类拥有 Cleaner 机制清理房间所需的资源。 在这里,它仅仅包含 numJunkPiles 属性,它代表混乱房间的数量。 更实际地说,它可能是一个 final 修饰的 long 类型的指向本地对等类的指针。 State 类实现了 Runnable 接口,其 run 方法最多只能调用一次,只能被我们在 Room 构造方法中用 Cleaner 机制注册 State 实例时得到的 Cleanable 调用。 对 run 方法的调用通过以下两种方法触发:通常,通过调用 Roomclose 方法内调用 Cleanableclean 方法来触发。 如果在 Room 实例有资格进行垃圾回收的时候客户端没有调用 close 方法,那么 Cleaner 机制将(希望)调用 Staterun 方法。

一个 State 实例不引用它的 Room 实例是非常重要的。如果它引用了,则创建了一个循环,阻止了 Room 实例成为垃圾收集的资格(以及自动清除)。因此,State 必须是静态的嵌内部类,因为非静态内部类包含对其宿主类的实例的引用(详见第 24 条)。同样,使用 lambda 表达式也是不明智的,因为它们很容易获取对宿主类对象的引用。

就像我们之前说的,Room 的 Cleaner 机制仅仅被用作一个安全网。如果客户将所有 Room 的实例放在 try-with-resource 块中,则永远不需要自动清理。行为良好的客户端如下所示:

1
2
3
4
5
6
7
public class Adult {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("Goodbye");
}
}
}

正如你所预料的,运行 Adult 程序会打印 Goodbye 字符串,随后打印 Cleaning room 字符串。但是如果时不合规矩的程序,它从来不清理它的房间会是什么样的?

1
2
3
4
5
6
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("Peace out");
}
}

你可能期望它打印出 Peace out,然后打印 Cleaning room 字符串,但在我的机器上,它从不打印 Cleaning room 字符串;仅仅是程序退出了。 这是我们之前谈到的不可预见性。 Cleaner 机制的规范说:“System.exit 方法期间的清理行为是特定于实现的。 不保证清理行为是否被调用。”虽然规范没有说明,但对于正常的程序退出也是如此。 在我的机器上,将 System.gc() 方法添加到 Teenager 类的 main 方法足以让程序退出之前打印 Cleaning room,但不能保证在你的机器上会看到相同的行为。

总之,除了作为一个安全网或者终止非关键的本地资源,不要使用 Cleaner 机制,或者是在 Java 9 发布之前的 finalizers 机制。即使是这样,也要当心不确定性和性能影响。

20. 接口优于抽象类

20. 接口优于抽象类

Java 有两种机制来定义允许多个实现的类型:接口和抽象类。 由于在 Java 8 [JLS 9.4.3] 中引入了接口的默认方法(default methods ),因此这两种机制都允许为某些实例方法提供实现。 一个主要的区别是要实现由抽象类定义的类型,类必须是抽象类的子类。 因为 Java 只允许单一继承,所以对抽象类的这种限制严格限制了它们作为类型定义的使用。 任何定义所有必需方法并服从通用约定的类都可以实现一个接口,而不管类在类层次结构中的位置。

现有的类可以很容易地进行改进来实现一个新的接口。 你只需添加所需的方法(如果尚不存在的话),并向类声明中添加一个 implements 子句。 例如,当 Comparable, Iterable, 和 Autocloseable 接口添加到 Java 平台时,很多现有类需要实现它们来加以改进。 一般来说,现有的类不能改进以继承一个新的抽象类。 如果你想让两个类继承相同的抽象类,你必须把它放在类型层级结构中的上面位置,它是两个类的祖先。 不幸的是,这会对类型层级结构造成很大的附带损害,迫使新的抽象类的所有后代对它进行子类化,无论这些后代类是否合适。

接口是定义混合类型(mixin)的理想选择。 一般来说,mixin 是一个类,除了它的“主类型”之外,还可以声明它提供了一些可选的行为。 例如,Comparable 是一个类型接口,它允许一个类声明它的实例相对于其他可相互比较的对象是有序的。 这样的接口被称为类型,因为它允许可选功能被“混合”到类型的主要功能。 抽象类不能用于定义混合类,这是因为它们不能被加载到现有的类中:一个类不能有多个父类,并且在类层次结构中没有合理的位置来插入一个类型。

接口允许构建非层级类型的框架。 类型层级对于组织某些事物来说是很好的,但是其他的事物并不是整齐地落入严格的层级结构中。 例如,假设我们有一个代表歌手的接口,和另一个代表作曲家的接口:

1
2
3
4
5
6
7
public interface Singer {
AudioClip sing(Song s);
}

public interface Songwriter {
Song compose(int chartPosition);
}

在现实生活中,一些歌手也是作曲家。 因为我们使用接口而不是抽象类来定义这些类型,所以单个类实现歌手和作曲家两个接口是完全允许的。 事实上,我们可以定义一个继承歌手和作曲家的第三个接口,并添加适合于这个组合的新方法:

1
2
3
4
public interface SingerSongwriter extends Singer, Songwriter {
AudioClip strum();
void actSensitive();
}

你并不总是需要这种灵活性,但是当你这样做的时候,接口是一个救星。 另一种方法是对于每个受支持的属性组合,包含一个单独的类的臃肿类层级结构。 如果类型系统中有 n 个属性,则可能需要支持 2n 种可能的组合。 这就是所谓的组合爆炸(combinatorial explosion)。 臃肿的类层级结构可能会导致具有许多方法的臃肿类,这些方法仅在参数类型上有所不同,因为类层级结构中没有类型来捕获通用行为。

接口通过包装类模式确保安全的,强大的功能增强成为可能(详见第 18 条)。 如果使用抽象类来定义类型,那么就让程序员想要添加功能,只能继承。 生成的类比包装类更弱,更脆弱。

当其他接口方法有明显的接口方法实现时,可以考虑向程序员提供默认形式的方法实现帮助。 有关此技术的示例,请参阅第 104 页的 removeIf 方法。如果提供默认方法,请确保使用@implSpec Javadoc 标记(条目 19)将它们文档说明为继承。

使用默认方法可以提供实现帮助多多少少是有些限制的。 尽管许多接口指定了 Object 类中方法(如 equalshashCode)的行为,但不允许为它们提供默认方法。 此外,接口不允许包含实例属性或非公共静态成员(私有静态方法除外)。 最后,不能将默认方法添加到不受控制的接口中。

但是,你可以通过提供一个抽象的骨架实现类(abstract skeletal implementation class)来与接口一起使用,将接口和抽象类的优点结合起来。 接口定义了类型,可能提供了一些默认的方法,而骨架实现类在原始接口方法的顶层实现了剩余的非原始接口方法。 继承骨架实现需要大部分的工作来实现一个接口。 这就是模板方法设计模式[Gamma95]。

按照惯例,骨架实现类被称为 AbstractInterface,其中 Interface 是它们实现的接口的名称。 例如,集合框架( Collections Framework)提供了一个框架实现以配合每个主要集合接口:AbstractCollectionAbstractSetAbstractListAbstractMap。 可以说,将它们称为 SkeletalCollectionSkeletalSetSkeletalListSkeletalMap 是有道理的,但是现在已经确立了抽象约定。 如果设计得当,骨架实现(无论是单独的抽象类还是仅由接口上的默认方法组成)可以使程序员非常容易地提供他们自己的接口实现。 例如,下面是一个静态工厂方法,在 AbstractList 的顶层包含一个完整的功能齐全的 List 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Concrete implementation built atop skeletal implementation
static List<Integer> intArrayAsList(int[] a) {
Objects.requireNonNull(a);
// The diamond operator is only legal here in Java 9 and later
// If you're using an earlier release, specify <Integer>
return new AbstractList<>() {
@Override
public Integer get(int i) {
return a[i]; // Autoboxing ([Item 6](https://www.safaribooksonline.com/library/view/effective-java-third/9780134686097/ch2.xhtml#lev6))
}

@Override
public Integer set(int i, Integer val) {
int oldVal = a[i];
a[i] = val; // Auto-unboxing
return oldVal; // Autoboxing
}

@Override
public int size() {
return a.length;
}
};

}

当你考虑一个 List 实现为你做的所有事情时,这个例子是一个骨架实现的强大的演示。 顺便说一句,这个例子是一个适配器(Adapter)[Gamma95],它允许一个 int 数组被看作 Integer 实例列表。 由于 int 值和整数实例(装箱和拆箱)之间的来回转换,其性能并不是非常好。 请注意,实现采用匿名类的形式(详见第 24 条)。

骨架实现类的优点在于,它们提供抽象类的所有实现的帮助,而不会强加抽象类作为类型定义时的严格约束。对于具有骨架实现类的接口的大多数实现者来说,继承这个类是显而易见的选择,但它不是必需的。如果一个类不能继承骨架的实现,这个类可以直接实现接口。该类仍然受益于接口本身的任何默认方法。此外,骨架实现类仍然可以协助接口的实现。实现接口的类可以将接口方法的调用转发给继承骨架实现的私有内部类的包含实例。这种被称为模拟多重继承的技术与条目 18 讨论的包装类模式密切相关。它提供了多重继承的许多好处,同时避免了缺陷。

编写一个骨架的实现是一个相对简单的过程,虽然有些乏味。 首先,研究接口,并确定哪些方法是基本的,其他方法可以根据它们来实现。 这些基本方法是你的骨架实现类中的抽象方法。 接下来,为所有可以直接在基本方法之上实现的方法提供接口中的默认方法,回想一下,你可能不会为诸如 Object 类中 equalshashCode 等方法提供默认方法。 如果基本方法和默认方法涵盖了接口,那么就完成了,并且不需要骨架实现类。 否则,编写一个声明实现接口的类,并实现所有剩下的接口方法。 为了适合于该任务,此类可能包含任何的非公共属性和方法。

作为一个简单的例子,考虑一下 Map.Entry 接口。 显而易见的基本方法是 getKeygetValue 和(可选的)setValue。 接口指定了 equalshashCode 的行为,并且在基本方面方面有一个 toString 的明显的实现。 由于不允许为 Object 类方法提供默认实现,因此所有实现均放置在骨架实现类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Skeletal implementation class
public abstract class AbstractMapEntry<K,V>
implements Map.Entry<K,V> {

// Entries in a modifiable map must override this method
@Override public V setValue(V value) {
throw new UnsupportedOperationException();
}

// Implements the general contract of Map.Entry.equals
@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry) o;
return Objects.equals(e.getKey(), getKey())
&& Objects.equals(e.getValue(), getValue());
}

// Implements the general contract of Map.Entry.hashCode
@Override
public int hashCode() {
return Objects.hashCode(getKey())
^ Objects.hashCode(getValue());
}

@Override
public String toString() {
return getKey() + "=" + getValue();
}

}

请注意,这个骨架实现不能在 Map.Entry 接口中实现,也不能作为子接口实现,因为默认方法不允许重写诸如 equalshashCodetoStringObject 类方法。

由于骨架实现类是为了继承而设计的,所以你应该遵循条目 19 中的所有设计和文档说明。为了简洁起见,前面的例子中省略了文档注释,但是好的文档在骨架实现中是绝对必要的,无论它是否包含 一个接口或一个单独的抽象类的默认方法。

与骨架实现有稍许不同的是简单实现,以 AbstractMap.SimpleEntry 为例。 一个简单的实现就像一个骨架实现,它实现了一个接口,并且是为了继承而设计的,但是它的不同之处在于它不是抽象的:它是最简单的工作实现。 你可以按照情况使用它,也可以根据情况进行子类化。

总而言之,一个接口通常是定义允许多个实现的类型的最佳方式。 如果你导出一个重要的接口,应该强烈考虑提供一个骨架的实现类。 在可能的情况下,应该通过接口上的默认方法提供骨架实现,以便接口的所有实现者都可以使用它。 也就是说,对接口的限制通常要求骨架实现类采用抽象类的形式。

50. 必要时进行防御性拷贝

50. 必要时进行防御性拷贝

愉快使用 Java 的原因,它是一种安全的语言(safe language)。 这意味着在缺少本地方法(native methods)的情况下,它不受缓冲区溢出,数组溢出,野指针以及其他困扰 C 和 C++ 等不安全语言的内存损坏错误的影响。 在一种安全的语言中,无论系统的任何其他部分发生什么,都可以编写类并确切地知道它们的不变量会保持不变。 在将所有内存视为一个巨大数组的语言中,这是不可能的。

即使在一种安全的语言中,如果不付出一些努力,也不会与其他类隔离。必须防御性地编写程序,假定类的客户端尽力摧毁类其不变量。随着人们更加努力地试图破坏系统的安全性,这种情况变得越来越真实,但更常见的是,你的类将不得不处理由于善意得程序员诚实错误而导致的意外行为。不管怎样,花时间编写在客户端行为不佳的情况下仍然保持健壮的类是值得的。

如果没有对象的帮助,另一个类是不可能修改对象的内部状态的,但是在无意的情况下提供这样的帮助却非常地容易。例如,考虑以下类,表示一个不可变的时间期间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Broken "immutable" time period class
public final class Period {
private final Date start;
private final Date end;

/**
* @param start the beginning of the period
* @param end the end of the period; must not precede start
* @throws IllegalArgumentException if start is after end
* @throws NullPointerException if start or end is null
*/
public Period(Date start, Date end) {
if (start.compareTo(end) > 0)
throw new IllegalArgumentException(
start + " after " + end);

this.start = start;
this.end = end;
}

public Date start() {
return start;
}

public Date end() {
return end;
}
... // Remainder omitted
}

乍一看,这个类似乎是不可变的,并强制执行不变式,即 period 实例的开始时间并不在结束时间之后。然而,利用 Date 类是可变的这一事实很容易违反这个不变式:

1
2
3
4
5
// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!

从 Java 8 开始,解决此问题的显而易见的方法是使用 Instant(或 LocalDateTimeZonedDateTime)代替Date,因为Instant和其他 java.time 包下的类是不可变的(条目 17)。Date 已过时,不应再在新代码中使用。 也就是说,问题仍然存在:有时必须在 API 和内部表示中使用可变值类型,本条目中讨论的技术也适用于这些时间。

为了保护 Period 实例的内部不受这种攻击,必须将每个可变参数的防御性拷贝应用到构造方法中,并将拷贝用作 Period 实例的组件,以替代原始实例:

1
2
3
4
5
6
7
8
9
// Repaired constructor - makes defensive copies of parameters
public Period(Date start, Date end) {
this.start = new Date(start.getTime());
this.end = new Date(end.getTime());

if (this.start.compareTo(this.end) > 0)
throw new IllegalArgumentException(
this.start + " after " + this.end);
}

有了新的构造方法后,前面的攻击将不会对 Period 实例产生影响。注意,防御性拷贝是在检查参数 (条目 49) 的有效性之前进行的,有效性检查是在拷贝上而不是在原始实例上进行的。虽然这看起来不自然,但却是必要的。它在检查参数和拷贝参数之间的漏洞窗口期间保护类不受其他线程对参数的更改的影响。在计算机安全社区中,这称为 time-of-check/time-of-use 或 TOCTOU 攻击[Viega01]。

还请注意,我们没有使用 Date 的 clone 方法来创建防御性拷贝。因为 Date 是非 final 的,所以 clone 方法不能保证返回类为 java.util.Date 的对象,它可以返回一个不受信任的子类的实例,这个子类是专门为恶意破坏而设计的。例如,这样的子类可以在创建时在私有静态列表中记录对每个实例的引用,并允许攻击者访问该列表。这将使攻击者可以自由控制所有实例。为了防止这类攻击,不要使用 clone 方法对其类型可由不可信任子类化的参数进行防御性拷贝。

虽然替换构造方法成功地抵御了先前的攻击,但是仍然可以对 Period 实例进行修改,因为它的访问器提供了对其可变内部结构的访问:

1
2
3
4
5
// Second attack on the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // Modifies internals of p!

为了抵御第二次攻击,只需修改访问器以返回可变内部字属性的防御性拷贝:

1
2
3
4
5
6
7
8
// Repaired accessors - make defensive copies of internal fields
public Date start() {
return new Date(start.getTime());
}

public Date end() {
return new Date(end.getTime());
}

使用新的构造方法和新的访问器,Period 是真正不可变的。 无论程序员多么恶意或不称职,根本没有办法违反一个 period 实例的开头不跟随其结束的不变量(不使用诸如本地方法和反射之类的语言外方法)。 这是正确的,因为除了 period 本身之外的任何类都无法访问 period 实例中的任何可变属性。 这些属性真正封装在对象中。

在访问器中,与构造方法不同,允许使用 clone 方法来制作防御性拷贝。 这是因为我们知道 Period 的内部 Date 对象的类是 java.util.Date,而不是一些不受信任的子类。 也就是说,由于条目 13 中列出的原因,通常最好使用构造方法或静态工厂来拷贝实例。

参数的防御性拷贝不仅仅适用于不可变类。 每次编写在内部数据结构中存储对客户端提供的对象的引用的方法或构造函数时,请考虑客户端提供的对象是否可能是可变的。 如果是,请考虑在将对象输入数据结构后,你的类是否可以容忍对象的更改。 如果答案是否定的,则必须防御性地拷贝对象,并将拷贝输入到数据结构中,以替代原始数据结构。 例如,如果你正在考虑使用客户端提供的对象引用作为内部 set 实例中的元素或作为内部 map 实例中的键,您应该意识到如果对象被修改后插入,对象的 set 或 map 的不变量将被破坏。

在将内部组件返回给客户端之前进行防御性拷贝也是如此。无论你的类是否是不可变的,在返回对可拜年的内部组件的引用之前,都应该三思。可能的情况是,应该返回一个防御性拷贝。记住,非零长度数组总是可变的。因此,在将内部数组返回给客户端之前,应该始终对其进行防御性拷贝。或者,可以返回数组的不可变视图。这两项技术都记载于条目 15。

可以说,所有这些的真正教训是,在可能的情况下,应该使用不可变对象作为对象的组件,这样就不必担心防御性拷贝(详见第 17 条)。在我们的 Period 示例中,使用 Instant(或 LocalDateTimeZonedDateTime),除非使用的是 Java 8 之前的版本。如果使用的是较早的版本,则一个选项是存储 Date.getTime() 返回的基本类型 long 来代替 Date 引用。

可能存在与防御性拷贝相关的性能损失,并且它并不总是合理的。如果一个类信任它的调用者不修改内部组件,也许是因为这个类和它的客户端都是同一个包的一部分,那么它可能不需要防御性的拷贝。在这些情况下,类文档应该明确指出调用者不能修改受影响的参数或返回值。

即使跨越包边界,在将可变参数集成到对象之前对其进行防御性拷贝也并不总是合适的。有些方法和构造方法的调用指示参数引用的对象的显式切换。当调用这样的方法时,客户端承诺不再直接修改对象。希望获得客户端提供的可变对象的所有权的方法或构造方法必须在其文档中明确说明这一点。

包含方法或构造方法的类,这些方法或构造方法的调用指示控制权的转移,这些类无法防御恶意客户端。 只有当一个类和它的客户之间存在相互信任,或者当对类的不变量造成损害时,除了客户之外,任何人都不会受到损害。 后一种情况的一个例子是包装类模式(详见第 18 条)。 根据包装类的性质,客户端可以通过在包装后直接访问对象来破坏类的不变性,但这通常只会损害客户端。

总之,如果一个类有从它的客户端获取或返回的可变组件,那么这个类必须防御性地拷贝这些组件。如果拷贝的成本太高,并且类信任它的客户端不会不适当地修改组件,则可以用文档替换防御性拷贝,该文档概述了客户端不得修改受影响组件的责任。

89. 对于实例控制,枚举类型优于 readResolve

89. 对于实例控制,枚举类型优于 readResolve

第 3 条讲述了 Singletion(单例)模式,并且给出了以下这个 Singletion 示例。这个类限制了对其构造器的访问,以确保永远只创建一个实例。

1
2
3
4
5
6
public class Elvis {
public static final Elvis INSTANCE = new Elvis();

private Elvis() { ... }
public void leaveTheBuilding() { ... }
}

正如在第 3 条中所提到的,如果在这个类上面增加 implements Serializable 的字样,它就不是一个单例。无论该类使用了默认的序列化形式,还是自定义的序列化形式(详见 87 条),都没有关系;也跟它是否使用了显式的 readObject(详见 88 条)无关。任何一个 readObject 方法,不管是显式的还是默认的,都会返回一个新建的实例,这个新建的实例不同于类初始化时创建的实例。

readResolve 特性允许你用 readObject 创建的实例代替另外一个实例[Serialization, 3.7]。对于一个正在被反序列化的对象,如果它的类定义了一个 readResolve 方法,并且具备正确的声明,那么在反序列化之后,新建对象上的 readResolve 方法就会被调用。然后,该方法返回的对象引用将会被回收,取代新建的对象。这个特性在绝大多数用法中,指向新建对象的引用不会再被保留,因此成为垃圾回收的对象。

如果 Elvis 类要实现 Serializable 接口,下面的 readResolve 方法就足以保证它的单例属性:

1
2
3
4
5
6
// readResolve for instance control - you can do better!
private Object readResolve() {
// Return the one true Elvis and let the garbage collector
// take care of the Elvis impersonator.
return INSTANCE;
}

该方法忽略了被反序列化的对象,只返回类初始化创建的那个特殊的 Elvis 实例。因此 Elvis 实例的序列化形式不应该包含任何实际的数据;所有的实例字段都应该被声明为 transient。事实上,如果依赖 ​​**readResolve​ 进行实例控制,带有对象引用类型的所有实例字段都必须声明为 ​transient。** 否则,那种破釜沉舟式的攻击者,就有可能在 readResolve 方法运行之前,保护指向反序列化对象的引用,采用的方式类似于在第 88 条中提到的 MutablePeriod 攻击。

这种攻击有点复杂,但是背后的思想十分简单。如果单例包含一个非 transient 的对象引用字段,这个字段的内容就可以在单例的 readResolve 方法之前被反序列化。当对象引用字段的内容被反序列化时,它就允许一个精心制作的流「盗用」指向最初被反序列化的单例对象引用。

以下是它更详细的工作原理。首先编写一个「盗用者」类,它既有 readResolve 方法,又有实例字段,实例字段指向被序列化的单例的引用,「盗用者」就「潜伏」在其中。在序列化流中,用「盗用者」的 readResolve 方法运行时,它的实例字段仍然引用部分反序列化(并且还没有被解析)的 Singletion。

「盗用者」的 readResolve 方法从它的实例字段中将引用复制到静态字段中,以便该引用可以在 readResolve 方法运行之后被访问到。然后这个方法为它所藏身的那个域返回一个正确的类型值。如果没有这么做,当序列化系统试着将「盗用者」引用保存到这个字段时,虚拟机就会抛出 ClassCastException

为了更具体的说明这一点,我们以如下这个单例模式为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Broken singleton - has nontransient object reference field!
public class Elvis implements Serializable {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { }

private String[] favoriteSongs =
{ "Hound Dog", "Heartbreak Hotel" };
public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}

private Object readResolve() {
return INSTANCE;
}
}

如下「盗用者」类,是根据以上描述构造的:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ElvisStealer implements Serializable {
static Elvis impersonator;
private static final long serialVersionUID = 0;
private Elvis payload;

private Object readResolve() {
// Save a reference to the "unresolved" Elvis instance
impersonator = payload;

// Return object of correct type for favoriteSongs field
return new String[] { "A Fool Such as I" };
}
}

下面是一个不完整的程序,它反序列化一个手工制作的流,为那个有缺陷的单例产生两个截然不同的实例。这个程序省略了反序列化方法,因为它与第 88 条一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class ElvisImpersonator {
// Byte stream couldn't have come from a real Elvis instance!
private static final byte[] serializedForm = {
(byte) 0xac, (byte) 0xed, 0x00, 0x05, 0x73, 0x72, 0x00, 0x05, 0x45,
0x6c, 0x76, 0x69, 0x73, (byte) 0x84, (byte) 0xe6, (byte) 0x93, 0x33,
(byte) 0xc3, (byte) 0xf4, (byte) 0x8b, 0x32, 0x02, 0x00, 0x01, 0x4c,
0x00, 0x0d, 0x66, 0x61, 0x76, 0x6f, 0x72, 0x69, 0x74, 0x65, 0x53,
0x6f, 0x6e, 0x67, 0x73, 0x74, 0x00, 0x12, 0x4c, 0x6a, 0x61, 0x76,
0x61, 0x2f, 0x6c, 0x61, 0x6e, 0x67, 0x2f, 0x4f, 0x62, 0x6a, 0x65,
0x63, 0x74, 0x3b, 0x78, 0x70, 0x73, 0x72, 0x00, 0x0c, 0x45, 0x6c,
0x76, 0x69, 0x73, 0x53, 0x74, 0x65, 0x61, 0x6c, 0x65, 0x72, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0x4c,
0x00, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x74, 0x00,
0x07, 0x4c, 0x45, 0x6c, 0x76, 0x69, 0x73, 0x3b, 0x78, 0x70, 0x71,
0x00, 0x7e, 0x00, 0x02
};

public static void main(String[] args) {
// Initializes ElvisStealer.impersonator and returns
// the real Elvis (which is Elvis.INSTANCE)
Elvis elvis = (Elvis) deserialize(serializedForm);
Elvis impersonator = ElvisStealer.impersonator;
elvis.printFavorites();
impersonator.printFavorites();
}
}

这个程序会产生如下的输出,最终证明可以创建两个截然不同的 Elvis 实例(包含两种不同的音乐品味):

1
2
[Hound Dog, Heartbreak Hotel]
[A Fool Such as I]

通过将 favoriteSongs 字段声明为 transient,可以修复这个问题,但是最好把 Elvis 做成一个单元素的枚举类型(详见第 3 条)。就如 ElvisStealer 攻击所示范的,用 readResolve 方法防止“临时”被反序列化的实例收到攻击者的访问,这种方法十分脆弱需要万分谨慎。

如果将一个可序列化的实例受控的类编写为枚举,Java 就可以绝对保证除了所声明的常量之外,不会有其他实例,除非攻击者恶意的使用了享受特权的方法。如 AccessibleObject.setAccessible。能够做到这一点的任何一位攻击者,已经拥有了足够的特权来执行任意的本地代码,后果不堪设想。将 Elvis 写成枚举的例子如下所示:

1
2
3
4
5
6
7
8
9
10
// Enum singleton - the preferred approach
public enum Elvis {
INSTANCE;

private String[] favoriteSongs = { "Hound Dog", "Heartbreak Hotel" };

public void printFavorites() {
System.out.println(Arrays.toString(favoriteSongs));
}
}

用 readResolve 进行实例控制并不过时。如果必须编写可序列化的实力受控的类,在编译时还不知道它的实例,你就无法将类表示成为一个枚举类型。

readResolve 的可访问性(accessibility)十分重要。 如果把 readResolve 方法放在一个 final 类上面,它应该是私有的。如果把 readResolve 方法放在一个非 final 类上,就必须认真考虑它的的访问性。如果它是私有的,就不适用于任何一个子类。如果它是包级私有的,就适用于同一个包内的子类。如果它是受保护的或者是公开的,并且子类没有覆盖它,对序列化的子类进行反序列化,就会产生一个超类实例,这样可能会导致 ClassCastException 异常。

总而言之,应该尽可能的使用枚举类型来实施实例控制的约束条件。如果做不到,同时又需要一个即可序列化又可以实例受控的类,就必须提供一个 readResolve 方法,并确保该类的所有实例化字段都被基本类型,或者是 transient 的。

37. 使用EnumMap替代序数索引

37. 使用 EnumMap 替代序数索引

有时可能会看到使用 ordinal 方法(详见第 35 条)来索引到数组或列表的代码。 例如,考虑一下这个简单的类来代表一种植物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Plant {
enum LifeCycle { ANNUAL, PERENNIAL, BIENNIAL }
final String name;
final LifeCycle lifeCycle;

Plant(String name, LifeCycle lifeCycle) {
this.name = name;
this.lifeCycle = lifeCycle;
}

@Override public String toString() {
return name;
}
}

现在假设你有一组植物代表一个花园,想要列出这些由生命周期组织的植物 (一年生,多年生,或双年生)。为此,需要构建三个集合,每个生命周期作为一个,并遍历整个花园,将每个植物放置在适当的集合中。一些程序员可以通过将这些集合放入一个由生命周期序数索引的数组中来实现这一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Using ordinal() to index into an array - DON'T DO THIS!
Set<Plant>[] plantsByLifeCycle =
(Set<Plant>[]) new Set[Plant.LifeCycle.values().length];

for (int i = 0; i < plantsByLifeCycle.length; i++)
plantsByLifeCycle[i] = new HashSet<>();

for (Plant p : garden)
plantsByLifeCycle[p.lifeCycle.ordinal()].add(p);

// Print the results
for (int i = 0; i < plantsByLifeCycle.length; i++) {
System.out.printf("%s: %s%n",
Plant.LifeCycle.values()[i], plantsByLifeCycle[i]);
}

这种方法是有效的,但充满了问题。 因为数组不兼容泛型(详见第 28 条),程序需要一个未经检查的转换,并且不会干净地编译。 由于该数组不知道索引代表什么,因此必须手动标记索引输出。 但是这种技术最严重的问题是,当你访问一个由枚举序数索引的数组时,你有责任使用正确的 int 值; int 不提供枚举的类型安全性。 如果你使用了错误的值,程序会默默地做错误的事情,如果你幸运的话,抛出一个 ArrayIndexOutOfBoundsException 异常。

有一个更好的方法来达到同样的效果。 该数组有效地用作从枚举到值的映射,因此不妨使用 Map。 更具体地说,有一个非常快速的 Map 实现,设计用于枚举键,称为 java.util.EnumMap。 下面是当程序重写为使用 EnumMap 时的样子:

1
2
3
4
5
6
7
8
9
10
11
// Using an EnumMap to associate data with an enum
Map<Plant.LifeCycle, Set<Plant>> plantsByLifeCycle =
new EnumMap<>(Plant.LifeCycle.class);

for (Plant.LifeCycle lc : Plant.LifeCycle.values())
plantsByLifeCycle.put(lc, new HashSet<>());

for (Plant p : garden)
plantsByLifeCycle.get(p.lifeCycle).add(p);

System.out.println(plantsByLifeCycle);

这段程序更简短,更清晰,更安全,运行速度与原始版本相当。 没有不安全的转换; 无需手动标记输出,因为 map 键是知道如何将自己转换为可打印字符串的枚举; 并且不可能在计算数组索引时出错。 EnumMap 与序数索引数组的速度相当,其原因是 EnumMap 内部使用了这样一个数组,但它对程序员的隐藏了这个实现细节,将 Map 的丰富性和类型安全性与数组的速度相结合。 请注意,EnumMap 构造方法接受键类Class型的 Class 对象:这是一个有限定的类型令牌(bounded type token),它提供运行时的泛型类型信息(条目 33)。

通过使用 stream(详见第 45 条)来管理 Map,可以进一步缩短以前的程序。 以下是最简单的基于 stream 的代码,它们在很大程度上重复了前面示例的行为:

1
2
3
// Naive stream-based approach - unlikely to produce an EnumMap!
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle)));

这个代码的问题在于它选择了自己的 Map 实现,实际上它不是 EnumMap,所以它不会与显式 EnumMap 的版本的空间和时间性能相匹配。 为了解决这个问题,使用 Collectors.groupingBy 的三个参数形式的方法,它允许调用者使用 mapFactory 参数指定 map 的实现:

1
2
3
4
// Using a stream and an EnumMap to associate data with an enum
System.out.println(Arrays.stream(garden)
.collect(groupingBy(p -> p.lifeCycle,
() -> new EnumMap<>(LifeCycle.class), toSet())));

这样的优化在像这样的示例程序中是不值得的,但是在大量使用 Map 的程序中可能是至关重要的。

基于 stream 版本的行为与 EmumMap 版本的行为略有不同。 EnumMap 版本总是为每个工厂生命周期生成一个嵌套 map 类,而如果花园包含一个或多个具有该生命周期的植物时,则基于流的版本才会生成嵌套 map 类。 因此,例如,如果花园包含一年生和多年生植物但没有两年生的植物,plantByLifeCycle 的大小在 EnumMap 版本中为三个,在两个基于流的版本中为两个。

你可能会看到数组索引(两次)的数组,用序数来表示从两个枚举值的映射。例如,这个程序使用这样一个数组来映射两个阶段到一个阶段转换(phase transition)(液体到固体表示凝固,液体到气体表示沸腾等等):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Using ordinal() to index array of arrays - DON'T DO THIS!
public enum Phase {
SOLID, LIQUID, GAS;

public enum Transition {
MELT, FREEZE, BOIL, CONDENSE, SUBLIME, DEPOSIT;
// Rows indexed by from-ordinal, cols by to-ordinal
private static final Transition[][] TRANSITIONS = {
{ null, MELT, SUBLIME },
{ FREEZE, null, BOIL },
{ DEPOSIT, CONDENSE, null }
};

// Returns the phase transition from one phase to another
public static Transition from(Phase from, Phase to) {
return TRANSITIONS[from.ordinal()][to.ordinal()];
}
}
}

这段程序可以运行,甚至可能显得优雅,但外观可能是骗人的。 就像前面显示的简单的花园示例一样,编译器无法知道序数和数组索引之间的关系。 如果在转换表中出错或者在修改 PhasePhase.Transition 枚举类型时忘记更新它,则程序在运行时将失败。 失败可能是 ArrayIndexOutOfBoundsExceptionNullPointerException 或(更糟糕的)沉默无提示的错误行为。 即使非空条目的数量较小,表格的大小也是 phase 的个数的平方。

同样,可以用 EnumMap 做得更好。 因为每个阶段转换都由一对阶段枚举来索引,所以最好将关系表示为从一个枚举(from 阶段)到第二个枚举(to 阶段)到结果(阶段转换)的 map。 与阶段转换相关的两个阶段最好通过将它们与阶段转换枚举相关联来捕获,然后可以用它来初始化嵌套的 EnumMap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Using a nested EnumMap to associate data with enum pairs
public enum Phase {
SOLID, LIQUID, GAS;

public enum Transition {
MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID);

private final Phase from;
private final Phase to;
Transition(Phase from, Phase to) {
this.from = from;
this.to = to;
}

// Initialize the phase transition map
private static final Map<Phase, Map<Phase, Transition>>
m = Stream.of(values()).collect(groupingBy(t -> t.from,
() -> new EnumMap<>(Phase.class),
toMap(t -> [t.to](http://t.to), t -> t,
(x, y) -> y, () -> new EnumMap<>(Phase.class))));

public static Transition from(Phase from, Phase to) {
return m.get(from).get(to);
}
}
}

初始化阶段转换的 map 的代码有点复杂。map 的类型是 Map<Phase, Map<Phase, Transition>>,意思是「从(源)阶段映射到从(目标)阶段到阶段转换映射。」这个 mapmap 使用两个收集器的级联序列进行初始化。 第一个收集器按源阶段对转换进行分组,第二个收集器使用从目标阶段到转换的映射创建一个 EnumMap。 第二个收集器 ((x, y) -> y)) 中的合并方法未使用;仅仅因为我们需要指定一个 map 工厂才能获得一个 EnumMap,并且 Collectors 提供伸缩式工厂,这是必需的。 本书的前一版使用显式迭代来初始化阶段转换 map。 代码更详细,但可以更容易理解。

现在假设想为系统添加一个新阶段:等离子体或电离气体。 这个阶段只有两个转变:电离,将气体转化为等离子体; 和去离子,将等离子体转化为气体。 要更新基于数组的程序,必须将一个新的常量添加到 Phase,将两个两次添加到 Phase.Transition,并用新的十六个元素版本替换原始的九元素阵列数组。 如果向数组中添加太多或太少的元素或者将元素乱序放置,那么如果运气不佳:程序将会编译,但在运行时会失败。 要更新基于 EnumMap 的版本,只需将 PLASMA 添加到阶段列表中,并将 IONIZE(GAS, PLASMA)DEIONIZE(PLASMA, GAS) 添加到阶段转换列表中:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Adding a new phase using the nested EnumMap implementation
public enum Phase {

SOLID, LIQUID, GAS, PLASMA;

public enum Transition {
MELT(SOLID, LIQUID), FREEZE(LIQUID, SOLID),
BOIL(LIQUID, GAS), CONDENSE(GAS, LIQUID),
SUBLIME(SOLID, GAS), DEPOSIT(GAS, SOLID),
IONIZE(GAS, PLASMA), DEIONIZE(PLASMA, GAS);
... // Remainder unchanged
}
}

该程序会处理所有其他事情,并且几乎不会出现错误。 在内部,mapmap 是通过数组的数组实现的,因此在空间或时间上花费很少,以增加清晰度,安全性和易于维护。

为了简便起见,上面的示例使用 null 来表示状态更改的缺失(其从目标到源都是相同的)。这不是很好的实践,很可能在运行时导致 NullPointerException。为这个问题设计一个干净、优雅的解决方案是非常棘手的,而且结果程序足够长,以至于它们会偏离这个条目的主要内容。

总之,使用序数来索引数组很不合适:改用 EnumMap。 如果你所代表的关系是多维的,请使用 EnumMap <...,EnumMap <... >>。 应用程序员应该很少使用 Enum.ordinal(详见第 35 条),如果使用了,也是一般原则的特例。

0%