杨柳亭

杨柳亭

67. 明智审慎地进行优化

67. 明智审慎地进行优化

有三条关于优化的格言是每个人都应该知道的:

比起任何其他单一原因(包括盲目愚蠢),计算上的过失更多的是以效率为名(不一定能实现)而犯下的。

​ —William A. Wulf [Wulf72]

不要去计较效率上的一些小小的得失,在 97% 的情况下,不成熟的优化才是一切问题的根源。

​ —Donald E. Knuth [Knuth74]

在优化方面,我们应该遵守两条规则:

​ 规则 1:不要进行优化。

​ 规则 2 (仅针对专家):还是不要进行优化,也就是说,在你还没有绝对清晰的未优化方案之前,请不要进行优化。

​ —M. A. Jackson [Jackson75]

所有这些格言都比 Java 编程语言早了 20 年。它们告诉我们关于优化的一个深刻的事实:很容易弊大于利,尤其是如果过早地进行优化。在此过程中,你可能会生成既不快速也不正确且无法轻松修复的软件。

不要为了性能而牺牲合理的架构。努力编写 好的程序,而不是快速的程序。 如果一个好的程序不够快,它的架构将允许它被优化。好的程序体现了信息隐藏的原则:在可能的情况下,它们在单个组件中本地化设计决策,因此可以在不影响系统其余部分的情况下更改单个决策(详见第 15 条)。

这并不意味着在程序完成之前可以忽略性能问题。实现上的问题可以通过以后的优化来解决,但是对于架构缺陷,如果不重写系统,就不可能解决限制性能的问题。在系统完成之后再改变设计的某个基本方面可能导致结构不良的系统难以维护和进化。因此,你必须在设计过程中考虑性能。

尽量避免限制性能的设计决策。 设计中最难以更改的组件是那些指定组件之间以及与外部世界的交互的组件。这些设计组件中最主要的是 API、线路层协议和持久数据格式。这些设计组件不仅难以或不可能在事后更改,而且所有这些组件都可能对系统能够达到的性能造成重大限制。

考虑API设计决策的性能结果。 使公共类型转化为可变,可能需要大量不必要的防御性复制(详见第 50 条)。类似地,在一个公共类中使用继承(在这个类中组合将是合适的)将该类永远绑定到它的超类,这会人为地限制子类的性能(详见第 18 条)。最后一个例子是,在 API 中使用实现类而不是接口将你绑定到特定的实现,即使将来可能会编写更快的实现也无法使用(详见第 64 条)。

API 设计对性能的影响是非常实际的。考虑 java.awt.Component 中的 getSize 方法。该性能很关键方法返回 Dimension 实例的决定,加上维度实例是可变的决定,强制该方法的任何实现在每次调用时分配一个新的 Dimension 实例。尽管在现代 VM 上分配小对象并不昂贵,但不必要地分配数百万个对象也会对性能造成实际损害。

存在几种 API 设计替代方案。理想情况下,Dimension 应该是不可变的(详见第 17 条);或者,getSize 可以被返回 Dimension 对象的原始组件的两个方法所替代。事实上,出于性能原因,在 Java 2 的组件中添加了两个这样的方法。然而,现有的客户端代码仍然使用 getSize 方法,并且仍然受到原始 API 设计决策的性能影响。

幸运的是,通常情况下,好的 API 设计与好的性能是一致的。为了获得良好的性能而改变 API 是一个非常糟糕的想法。 导致你改变 API 的性能问题,可能在平台或其他底层软件的未来版本中消失,但是改变的 API 和随之而来的问题将永远伴随着你。

一旦你仔细地设计了你的程序,成了一个清晰、简洁、结构良好的实现,那么可能是时候考虑优化了,假设此时你还不满意程序的性能。

记得 Jackson 的两条优化规则是「不要做」和「(只针对专家)」。先别这么做。他本可以再加一个:在每次尝试优化之前和之后测量性能。 你可能会对你的发现感到惊讶。通常,试图做的优化通常对于性能并没有明显的影响;有时候,还让事情变得更糟。主要原因是很难猜测程序将时间花费在哪里。程序中你认为很慢的部分可能并没有问题,在这种情况下,你是在浪费时间来优化它。一般认为,程序将 90% 的时间花费在了 10% 的代码上。

分析工具可以帮助你决定将优化工作的重点放在哪里。这些工具提供了运行时信息,比如每个方法大约花费多少时间以及调用了多少次。除了关注你的调优工作之外,这还可以提醒你是否需要改变算法。如果程序中潜伏着平方级(或更差)的算法,那么再多的调优也无法解决这个问题。你必须用一个更有效的算法来代替这个算法。系统中的代码越多,使用分析器就越重要。这就像大海捞针:大海越大,金属探测器就越有用。另一个值得特别提及的工具是 jmh,它不是一个分析器,而是一个微基准测试框架,提供了对 Java 代码性能无与伦比的预测性。

与 C 和 C++ 等更传统的语言相比,Java 甚至更需要度量尝试优化的效果,因为 Java 的性能模型更弱:各种基本操作的相对成本没有得到很好的定义。程序员编写的内容和 CPU 执行的内容之间的「抽象鸿沟」更大,这使得可靠地预测优化的性能结果变得更加困难。有很多关于性能的传说流传开来,但最终被证明是半真半假或彻头彻尾的谎言。

Java 的性能模型不仅定义不清,而且在不同的实现、不同的发布版本、不同的处理器之间都有所不同。如果你要在多个实现或多个硬件平台上运行程序,那么度量优化对每个平台的效果是很重要的。有时候,你可能会被迫在不同实现或硬件平台上的性能之间进行权衡。

自本条目首次编写以来的近 20 年里,Java 软件栈的每个组件都变得越来越复杂,从处理器到 vm 再到库,Java 运行的各种硬件都有了极大的增长。所有这些加在一起,使得 Java 程序的性能比 2001 年更难以预测,而对它进行度量的需求也相应增加。

总而言之,不要努力写快的程序,要努力写好程序;速度自然会提高。但是在设计系统时一定要考虑性能,特别是在设计API、线路层协议和持久数据格式时。当你完成了系统的构建之后,请度量它的性能。如果足够快,就完成了。如果没有,利用分析器找到问题的根源,并对系统的相关部分进行优化。第一步是检查算法的选择:再多的底层优化也不能弥补算法选择的不足。根据需要重复这个过程,在每次更改之后测量性能,直到你满意为止。

18. 组合优于继承

18. 组合优于继承

继承是实现代码重用的有效方式,但并不总是最好的工具。使用不当,会导致脆弱的软件。 在包中使用继承是安全的,其中子类和父类的实现都在同一个程序员的控制之下。对应专门为了继承而设计的,并且有文档说明的类来说(详见第 19 条),使用继承也是安全的。 然而,从普通的具体类跨越包级边界继承,是危险的。 提醒一下,本书使用「继承」一词,其含义是实现继承(当一个类扩展另一个类时)。 在本条目中讨论的问题不适用于接口继承(当类实现接口,或者当接口继承另一个接口时)。

与方法调用不同,继承打破了封装[Snyder86]。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。

为了具体说明,假设有一个使用 HashSet 的程序。 为了调整程序的性能,需要查询 HashSet ,从创建它之后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时数量也会下降)。 为了提供这个功能,编写了一个 HashSet 变体,它保留了尝试元素插入的数量,并导出了这个插入数量的一个访问方法。 HashSet 类包含两个添加元素的方法,分别是 addaddAll,所以我们重写这两个方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Broken - Inappropriate use of inheritance!
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;

public InstrumentedHashSet() {
}

public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}

这个类看起来很合理,但是不能正常工作。 假设创建一个实例并使用 addAll 方法添加三个元素。 顺便提一句,请注意,下面代码使用在 Java 9 中添加的静态工厂方法 List.of 来创建一个列表;如果使用的是早期版本,请改为使用 Arrays.asList

1
2
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));

我们期望 getAddCount 方法返回的结果是 3,但实际上返回了 6。哪里出来问题?在 HashSet 内部,addAll 方法是基于它的 add 方法来实现的,即使 HashSet 文档中没有指名其实现细节,倒也是合理的。InstrumentedHashSet 中的 addAll 方法首先给 addCount 属性设置为 3,然后使用 super.addAll 方法调用了 HashSetaddAll 实现。然后反过来又调用在 InstrumentedHashSet 类中重写的 add 方法,每个元素调用一次。这三次调用又分别给 addCount 加 1,所以,一共增加了 6:通过 addAll 方法每个增加的元素都被计算了两次。

我们可以通过消除 addAll 方法的重写来“修复”子类。 尽管生成的类可以正常工作,但是它依赖于它的正确方法,因为 HashSetaddAll 方法是在其 add 方法之上实现的。 这个“自我使用(self-use)”是一个实现细节,并不保证在 Java 平台的所有实现中都可以适用,并且可以随发布版本而变化。 因此,产生的 InstrumentedHashSet 类是脆弱的。

稍微好一点的做法是,重写 addAll 方法遍历指定集合,为每个元素调用 add 方法一次。 不管 HashSetaddAll 方法是否在其 add 方法上实现,都会保证正确的结果,因为 HashSetaddAll 实现将不再被调用。然而,这种技术并不能解决所有的问题。 这相当于重新实现了父类方法,这样的方法可能不能确定是否是自用(self-use)的,实现起来也是困难的,耗时的,容易出错的,并且可能会降低性能。 此外,这种方式并不能总是奏效,因为子类无法访问一些私有属性,所以有些方法就无法实现。

导致子类脆弱的一个相关原因是,它们的父类在后续的发布版本中可以添加新的方法。假设一个程序的安全性依赖于这样一个事实:所有被插入到集中的元素都满足一个先决条件。可以通过对集合进行子类化,然后并重写所有添加元素的方法,以确保在添加每个元素之前满足这个先决条件,来确保这一问题。如果在后续的版本中,父类没有新增添加元素的方法,那么这样做没有问题。但是,一旦父类增加了这样的新方法,则很有可能由于调用了未被重写的新方法,将非法的元素添加到子类的实例中。这不是个纯粹的理论问题。在把 HashtableVector 类加入到 Collections 框架中的时候,就修复了几个类似性质的安全漏洞。

这两个问题都源于重写方法。 如果仅仅添加新的方法并且不要重写现有的方法,可能会认为继承一个类是安全的。 虽然这种扩展更为安全,但这并非没有风险。 如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同签名和不同返回类型的方法,那么你的子类编译失败[JLS,8.4.8.3]。 如果已经为子类提供了一个与新的父类方法具有相同签名和返回类型的方法,那么你现在正在重写它,因此将遇到前面所述的问题。 此外,你的方法是否会履行新的父类方法的约定,这是值得怀疑的,因为在你编写子类方法时,这个约定还没有写出来。

幸运的是,有一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是 现有类的实例引用,这种设计被称为组合(composition),因为现有的类成为新类的组成部分。新类中的每个实例方法调用现有类的包含实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法被称为转发方法。由此产生的类将坚如磐石,不依赖于现有类的实现细节。即使将新的方法添加到现有的类中,也不会对新类产生影响。为了具体说用,下面代码使用组合和转发方法替代 InstrumentedHashSet 类。请注意,实现分为两部分,类本身和一个可重用的转发类,其中包含所有的转发方法,没有别的方法:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
// Reusable forwarding class
import java.util.Collection;
import java.util.Iterator;
import java.util.Set;

public class ForwardingSet<E> implements Set<E> {

private final Set<E> s;

public ForwardingSet(Set<E> s) {
this.s = s;
}

public void clear() {
s.clear();
}

public boolean contains(Object o) {
return s.contains(o);
}

public boolean isEmpty() {
return s.isEmpty();
}

public int size() {
return s.size();
}

public Iterator<E> iterator() {
return s.iterator();
}

public boolean add(E e) {
return s.add(e);
}

public boolean remove(Object o) {
return s.remove(o);
}

public boolean containsAll(Collection<?> c) {
return s.containsAll(c);
}

public boolean addAll(Collection<? extends E> c) {
return s.addAll(c);
}

public boolean removeAll(Collection<?> c) {
return s.removeAll(c);
}

public boolean retainAll(Collection<?> c) {
return s.retainAll(c);
}

public Object[] toArray() {
return s.toArray();
}

public <T> T[] toArray(T[] a) {
return s.toArray(a);
}

@Override
public boolean equals(Object o) {
return s.equals(o);
}

@Override
public int hashCode() {
return s.hashCode();
}

@Override
public String toString() {
return s.toString();
}
}
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
// Wrapper class - uses composition in place of inheritance
import java.util.Collection;
import java.util.Set;

public class InstrumentedSet<E> extends ForwardingSet<E> {

private int addCount = 0;

public InstrumentedSet(Set<E> s) {
super(s);
}

@Override public boolean add(E e) {
addCount++;
return super.add(e);
}

@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}

public int getAddCount() {
return addCount;
}
}

InstrumentedSet 类的设计是通过存在的 Set 接口来实现的,该接口包含 HashSet 类的功能特性。除了功能强大,这个设计是非常灵活的。InstrumentedSet 类实现了 Set 接口,并有一个构造方法,其参数也是 Set 类型的。本质上,这个类把 Set 转换为另一个类型 Set, 同时添加了计数的功能。与基于继承的方法不同,该方法仅适用于单个具体类,并且父类中每个需要支持构造方法,提供单独的构造方法,所以可以使用包装类来包装任何 Set 实现,并且可以与任何预先存在的构造方法结合使用:

1
2
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));

InstrumentedSet 类甚至可以用于临时替换没有计数功能下使用的集合实例:

1
2
3
4
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // Within this method use iDogs instead of dogs
}

InstrumentedSet 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。 这也被称为装饰器模式[Gamma95],因为 InstrumentedSet 类通过添加计数功能来“装饰”一个集合。 有时组合和转发的结合被不精确地地称为委托(delegation)。 从技术上讲,除非包装对象把自身传递给被包装对象,否则不是委托[Lieberman86; Gamma95]。

包装类的缺点很少。 一个警告是包装类不适合在回调框架(callback frameworks)中使用,其中对象将自我引用传递给其他对象以用于后续调用(「回调」)。 因为一个被包装的对象不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。 这被称为 SELF 问题[Lieberman86]。 有些人担心转发方法调用的性能影响,以及包装对象对内存占用。 两者在实践中都没有太大的影响。 编写转发方法有些繁琐,但是只需为每个接口编写一次可重用的转发类,并且提供转发类。 例如,Guava 为所有的 Collection 接口提供转发类[Guava]。

只有在子类真的是父类的子类型的情况下,继承才是合适的。 换句话说,只有在两个类之间存在「is-a」关系的情况下,B 类才能继承 A 类。 如果你试图让 B 类继承 A 类时,问自己这个问题:每个 B 都是 A 吗? 如果你不能明确的以“是的”来回答这个问题,那么 B 就不应该继承 A。如果答案是否定的,那么 B 通常包含一个 A 的私有实例,并且暴露一个不同的 API :A 不是 B 的重要部分 ,只是其实现细节。

在 Java 平台类库中有一些明显的违反这个原则的情况。 例如,stacks 实例并不是 vector 实例,所以 Stack 类不应该继承 Vector 类。 同样,一个属性列表不是一个哈希表,所以 Properties 不应该继承 Hashtable 类。 在这两种情况下,组合方式更可取。

如果在合适组合的地方使用继承,则会不必要地公开实现细节。由此产生的 API 将与原始实现联系在一起,永远限制类的性能。更严重的是,通过暴露其内部,客户端可以直接访问它们。至少,它可能导致混淆语义。例如,属性 p 指向 Properties 实例,那么 p.getProperty(key)p.get(key) 就有可能返回不同的结果:前者考虑了默认的属性表,而后者是继承 Hashtable 的,它则没有考虑默认属性列表。最严重的是,客户端可以通过直接修改超父类来破坏子类的不变性。在 Properties 类,设计者希望只有字符串被允许作为键和值,但直接访问底层的 Hashtable 允许违反这个不变性。一旦违反,就不能再使用属性 API 的其他部分(loadstore 方法)。在发现这个问题的时候,纠正这个问题为时已晚,因为客户端依赖于使用非字符串键和值了。

在决定使用继承来代替组合之前,你应该问自己最后一组问题。对于试图继承的类,它的 API 有没有缺陷呢? 如果有,你是否愿意将这些缺陷传播到你的类的 API 中?继承传播父类的 API 中的任何缺陷,而组合可以让你设计一个隐藏这些缺陷的新 API。

总之,继承是强大的,但它是有问题的,因为它违反封装。 只有在子类和父类之间存在真正的子类型关系时才适用。 即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承可能会导致脆弱性。 为了避免这种脆弱性,使用组合和转发代替继承,特别是如果存在一个合适的接口来实现包装类。 包装类不仅比子类更健壮,而且更强大。

78. 同步访问共享的可变数据

78. 同步访问共享的可变数据

关键字 synchronized 可以保证在同一时刻,只有一个线程可以执行某一个方法,或者某一个代码块。许多程序员把同步的概念仅仅理解为一种互斥( mutual exclusion )的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象内部不一致的状态。按照这种观点,对象被创建的时候处于一致的状态(详见第 17 条),当有方法访问它的时候,它就被锁定了。这些方法观察到对象的状态,并且可能会引起状态转变( statetransition ),即把对象从一种一致的状态转换到另一种一致的状态。正确地使用同步可以保证没有任何方法会看到对象处于不一致的状态中。

这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个线程,都能看到由同一个锁保护的之前所有的修改效果。

Java 语言规范保证读或者写一个变量是原子的( atomic ),除非这个变量的类型为 long 或者 double [JLS , 17.4, 17.7] 。换句话说,读取一个非 long 或 double 类型的变量,可以保证返回值是某个线程保存在该变量中的, 即使多个线程在没有同步的情况下并发地修改这个变量也是如此。

你可能昕说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的。 为了在线程之间进行可靠的通信,也为了互斥访问,同步是必要的。 这归因于 Java 语言规范中的内存模型(memory model),它规定了一个线程所做的变化何时以及如何变成对其他线程可见[JLS ,17.4; Goetz06, 16]。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。以下面这个阻止一个线程妨碍,另一个线程的任务为例。Java 的类库中提供了 Thread.stop 方法,但是在很久以前就不提倡使用该方法了,因为它本质上是不安全的一一使用它会导致数据遭到破坏。 千万不要使用 ​​**Thread.stop​ 方法。** 要阻止一个线程妨碍另一个线程,建议的做法是让第一个线程轮询( poll ) 一个 boolean 字段,这个字段一开始为 false ,但是可以通过第二个线程设置为 true ,以表示第一个线程将终止自己。由于 boolean 字段的读和写操作都是原子的,程序员在访问这个字段的时候不再需要使用同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Broken! - How long would you expect this program to run?
public class StopThread {
private static Boolean stopRequested;

public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}

你可能期待这个程序运行大约一秒钟左右,之后主线程将 stopRequested 设置为 true ,致使后台线程的循环终止。但是在我的机器上,这个程序永远不会终止:因为后台线程永远在循环!

问题在于,由于没有同步,就不能保证后台线程何时‘看到’主线程对 stopRequested 的值所做的改变。没有同步,虚拟机将以下代码:

1
2
while (!stopRequested)
i++;

转变成这样:

1
2
3
if (!stopRequested)
while (true)
i++;

这种优化称作提升( hoisting ),正是 OpenJDK Server VM 的工作。结果是一个活性失败
(liveness failure):这个程序并没有得到提升。修正这个问题的一种方式是同步访问 stopRequested
字段。这个程序会如预期般在大约一秒之内终止:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Properly synchronized cooperative thread termination
public class StopThread {
private static Boolean stopRequested;
private static synchronized void requestStop() {
stopRequested = true;
}
private static synchronized Boolean stopRequested() {
return stopRequested;
}
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested())
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
requestStop();
}
}

注意写方法( requestStop )和读方法( stopRequested )都被同步了。只同步写方法还不够! 除非读和写操作都被同步,否则无法保证同步能起作用。 有时候,会在某些机器上看到只同步了写(或读)操作的程序看起来也能正常工作,但是在这种情况下,表象具有很大的欺骗性。

StopThread 中被同步方法的动作即使没有同步也是原子的。换句话说,这些方法的同步只是为了它的通信效果,而不是为了互斥访问。虽然循环的每个迭代中的同步开销很小,还是有其他更正确的替代方法,它更加简洁,性能也可能更好。如果 stopRequested 被声明为 volatile ,第二种版本的 StopThread 中的锁就可以省略。虽然 volatile 修饰符不执行互斥访问,但它可以保证任何一个线程在读取该字段的时候都将看到最近刚刚被写入的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Cooperative thread termination with a volatile field
public class StopThread {
private static volatile Boolean stopRequested;
public static void main(String[] args)
throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested)
i++;
});
backgroundThread.start();
TimeUnit.SECONDS.sleep(1);
stopRequested = true;
}
}

在使用 volatile 的时候务必要小心。以下面的方法为例,假设它要产生序列号:

1
2
3
4
5
6
// Broken - requires synchronization!
private static volatile int nextSerialNumber = 0;

public static int generateSerialNumber() {
return nextSerialNumber++;
}

这个方法的目的是要确保每个调用都返回不同的值(只要不超过 232 个调用) 。这个方法的状态只包含一个可原子访问的字段: nextSerialNumber ,这个字段的所有可能的值都是合法的。因此,不需要任何同步来保护它的约束条件。然而,如果没有同步,这个方法仍然无法正确地工作。

问题在于,增量操作符(++)不是原子的。它在 nextSerialNumber 字段中执行两项操作:首先它读取值,然后写回一个新值,相当于原来的值再加上 1。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个字段第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是安全性失败( safety failure ):这个程序会计算出错误的结果。

修正 generateSerialNumber 方法的一种方法是在它的声明中增加 synchronized 修饰符。这样可以确保多个调用不会交叉存取,确保每个调用都会看到之前所有调用的效果。一旦这么做,就可以且应该从 nextSerialNumber 中删除 volatile 修饰符。为了保护这个方法,要用 long 代替 int ,或者在 nextSerialNumber 要进行包装时抛出异常。

最好还是遵循第 59 条中的建议,使用 AtomicLong 类,它是 java.util.concurrent.atomic 的组成部分。这个包为在单个变量上进行免锁定、线程安全的编程提供了基本类型。虽然 volatile 只提供了同步的通信效果,但这个包还提供了原子性。这正是你想让 generateSerialNumber 完成的工作,并且它可能比同步版本完成得更好:

1
2
3
4
5
6
// Lock-free synchronization with java.util.concurrent.atomic
private static final Atomiclong nextSerialNum = new Atomiclong();

public static long generateSerialNumber() {
return nextSerialNum.getAndIncrement();
}

避免本条目中所讨论到的问题的最佳办法是不共享可变的数据。要么共享不可变的数据(详见第 17 条),要么压根不共享。换句话说, 将可变数据限制在单个线程中。 如果采用这一策略,对它建立文档就很重要,以便它可以随着程序的发展而得到维护。深刻地理解正在使用的框架和类库也很重要,因为它们引入了你不知道的线程。

让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,它只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作高效不可变( effectively immutable ) [Goetz06, 3.5.4] 。将这种对象引用从一个线程传递到其他的线程被称作安全发布( safe publication) [Goetz06, 3.5.3] 。安全发布对象引用有许多种方法:可以将它保存在静态字段巾,作为类初始化的一部分;可以将它保存在 volatile 字段、final 字段或者通过正常锁定访问的字段中;或者可以将它放到并发的集合中(详见第 81 条)。

总而言之, 当多个线程共享可变数据的时候,每个读或者写数据的线程都必须执行同步。 如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活跃性失败( liveness failure )和安全性失败( safety failure ) 。这样的失败是最难调试的。它们可能是间歇性的,且与时间相关,程序的行为在不同的虚拟机上可能根本不同。如果只需要线程之间的交互通信,而不需要互斥, volatile 修饰符就是一种可以接受的同步形式,但要正确地使用它可能需要一些技巧。

44. 优先使用标准的函数式接口

44. 优先使用标准的函数式接口

现在 Java 已经有 lambda 表达式,编写 API 的最佳实践已经发生了很大的变化。 例如,模板方法模式[Gamma95],其中一个子类重写原始方法以专门化其父类的行为,变得没有那么吸引人。 现代替代的选择是提供一个静态工厂或构造方法来接受函数对象以达到相同的效果。 通常地说,可以编写更多以函数对象为参数的构造方法和方法。 选择正确的函数式参数类型需要注意。

考虑 LinkedHashMap。 可以通过重写其受保护的 removeEldestEntry 方法将此类用作缓存,每次将新的 key 值加入到 map 时都会调用该方法。 当此方法返回 true 时,map 将删除传递给该方法的最久条目。 以下代码重写允许 map 增长到一百个条目,然后在每次添加新 key 值时删除最老的条目,并保留最近的一百个条目:

1
2
3
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > 100;
}

这种技术很有效,但是你可以用 lambdas 做得更好。如果 LinkedHashMap 是现在编写的,那么它将有一个静态的工厂或构造方法来获取函数对象。查看 removeEldestEntry 方法的声明,你可能会认为函数对象应该接受一个 Map.Entry<K,V> 并返回一个布尔值,但是这并不完全是这样:removeEldestEntry 方法调用 size() 方法来获取条目的数量,因为 removeEldestEntry 是 map 上的一个实例方法。传递给构造方法的函数对象不是 map 上的实例方法,无法捕获,因为在调用其工厂或构造方法时 map 还不存在。因此,map 必须将自己传递给函数对象,函数对象把 map 以及最就的条目作为输入参数。如果要声明这样一个功能接口,应该是这样的:

1
2
3
4
5
// Unnecessary functional interface; use a standard one instead.
@FunctionalInterface
interface EldestEntryRemovalFunction<K,V>{
boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}

这个接口可以正常工作,但是你不应该使用它,因为你不需要为此目的声明一个新的接口。 java.util.function 包提供了大量标准函数式接口供你使用。 如果其中一个标准函数式接口完成这项工作,则通常应该优先使用它,而不是专门构建的函数式接口。 这将使你的 API 更容易学习,通过减少其不必要概念,并将提供重要的互操作性好处,因为许多标准函数式接口提供了有用的默认方法。 例如,Predicate 接口提供了组合判断的方法。 在我们的 LinkedHashMap 示例中,标准的 BiPredicate<Map<K,V>, Map.Entry<K,V>> 接口应优先于自定义的 EldestEntryRemovalFunction 接口的使用。

java.util.Function 中有 43 个接口。不能指望全部记住它们,但是如果记住了六个基本接口,就可以在需要它们时派生出其余的接口。基本接口操作于对象引用类型。Operator 接口表示方法的结果和参数类型相同。Predicate 接口表示其方法接受一个参数并返回一个布尔值。Function 接口表示方法其参数和返回类型不同。Supplier 接口表示一个不接受参数和返回值 (或「供应」) 的方法。最后,Consumer 表示该方法接受一个参数而不返回任何东西,本质上就是使用它的参数。六种基本函数式接口概述如下:

接口 方法 示例
UnaryOperator<T> T apply(T t) String::toLowerCase
BinaryOperator<T> T apply(T t1, T t2) BigInteger::add
Predicate<T> boolean test(T t) Collection::isEmpty
Function<T,R> R apply(T t) Arrays::asList
Supplier<T> T get() Instant::now
Consumer<T> void accept(T t) System.out::println

在处理基本类型 int,long 和 double 的操作上,六个基本接口中还有三个变体。 它们的名字是通过在基本接口前加一个基本类型而得到的。 因此,例如,一个接受 int 的 Predicate 是一个 IntPredicate,而一个接受两个 long 值并返回一个 long 的二元运算符是一个 LongBinaryOperator。 除 Function 接口变体通过返回类型进行了参数化,其他变体类型都没有参数化。 例如,LongFunction<int[]> 使用 long 类型作为参数并返回了 int[] 类型。

Function 接口还有九个额外的变体,当结果类型为基本类型时使用。 源和结果类型总是不同,因为从类型到它自身的函数是 UnaryOperator。 如果源类型和结果类型都是基本类型,则使用带有 SrcToResult 的前缀 Function,例如 LongToIntFunction(六个变体)。如果源是一个基本类型,返回结果是一个对象引用,那么带有 ToObj 的前缀 Function,例如 DoubleToObjFunction (三种变体)。

有三个包含两个参数版本的基本功能接口,使它们有意义:BiPredicate <T,U>BiFunction <T,U,R>BiConsumer <T,U>。 也有返回三种相关基本类型的 BiFunction 变体:ToIntBiFunction <T,U>ToLongBiFunction<T,U>ToDoubleBiFunction <T,U>Consumer 有两个变量,它们带有一个对象引用和一个基本类型:ObjDoubleConsumer <T>ObjIntConsumer <T>ObjLongConsumer <T>。 总共有九个两个参数版本的基本接口。

最后,还有一个 BooleanSupplier 接口,它是 Supplier 的一个变体,它返回布尔值。 这是任何标准函数式接口名称中唯一明确提及的布尔类型,但布尔返回值通过 Predicate 及其四种变体形式支持。 前面段落中介绍的 BooleanSupplier 接口和 42 个接口占所有四十三个标准功能接口。 无可否认,这是非常难以接受的,并且不是非常正交的。 另一方面,你所需要的大部分功能接口都是为你写的,而且它们的名字是经常性的,所以在你需要的时候不应该有太多的麻烦。

大多数标准函数式接口仅用于提供对基本类型的支持。 不要试图使用基本的函数式接口来装箱基本类型的包装类而不是基本类型的函数式接口。 虽然它起作用,但它违反了第 61 条中的建议:「优先使用基本类型而不是基本类型的包装类」。使用装箱基本类型的包装类进行批量操作的性能后果可能是致命的。

现在你知道你应该通常使用标准的函数式接口来优先编写自己的接口。 但是,你应该什么时候写自己的接口? 当然,如果没有一个标准模块能够满足您的需求,例如,如果需要一个带有三个参数的 Predicate,或者一个抛出检查异常的 Predicate,那么需要编写自己的代码。 但有时候你应该编写自己的函数式接口,即使与其中一个标准的函数式接口的结构相同。

考虑我们的老朋友 Comparator <T>,它的结构与 ToIntBiFunction <T, T> 接口相同。 即使将前者添加到类库时后者的接口已经存在,使用它也是错误的。 Comparator 值得拥有自己的接口有以下几个原因。 首先,它的名称每次在 API 中使用时都会提供优秀的文档,并且使用了很多。 其次,Comparator 接口对构成有效实例的构成有强大的要求,这些要求构成了它的普遍契约。 通过实现接口,就要承诺遵守契约。 第三,接口配备很多了有用的默认方法来转换和组合多个比较器。

如果需要一个函数式接口与 Comparator 共享以下一个或多个特性,应该认真考虑编写一个专用函数式接口,而不是使用标准函数式接口:

  • 它将被广泛使用,并且可以从描述性名称中受益。
  • 它拥有强大的契约。
  • 它会受益于自定义的默认方法。

如果选择编写你自己的函数式接口,请记住它是一个接口,因此应非常小心地设计(详见第 21 条)。

请注意,EldestEntryRemovalFunction 接口(第 199 页)标有 @FunctionalInterface 注解。 这种注解在类型类似于 @Override。 这是一个程序员意图的陈述,它有三个目的:它告诉读者该类和它的文档,该接口是为了实现 lambda 表达式而设计的;它使你保持可靠,因为除非只有一个抽象方法,否则接口不会编译; 它可以防止维护人员在接口发生变化时不小心地将抽象方法添加到接口中。 始终使用 ​​**@FunctionalInterface​ 注解标注你的函数式接口。**

最后一点应该是关于在 api 中使用函数接口的问题。不要提供具有多个重载的方法,这些重载在相同的参数位置上使用不同的函数式接口,如果这样做可能会在客户端中产生歧义。这不仅仅是一个理论问题。ExecutorServicesubmit 方法可以采用 Callable<T>Runnable 接口,并且可以编写需要强制类型转换以指示正确的重载的客户端程序(详见第 52 条)。避免此问题的最简单方法是不要编写在相同的参数位置中使用不同函数式接口的重载。这是条目 52 中建议的一个特例,「明智地使用重载」。

总之,现在 Java 已经有了 lambda 表达式,因此必须考虑 lambda 表达式来设计你的 API。 在输入上接受函数式接口类型并在输出中返回它们。 一般来说,最好使用 java.util.function.Function 中提供的标准接口,但请注意,在相对罕见的情况下,最好编写自己的函数式接口。

82. 文档应包含线程安全属性

82. 文档应包含线程安全属性

类在其方法并发使用时的行为是其与客户端约定的重要组成部分。如果你没有记录类在这一方面的行为,那么它的用户将被迫做出假设。如果这些假设是错误的,生成的程序可能缺少足够的同步(详见 78 条)或过度的同步(详见 79 条)。无论哪种情况,都可能导致严重的错误。

你可能听说过,可以通过在方法的文档中查找 synchronized 修饰符来判断方法是否线程安全。这个观点有好些方面是错误的。在正常操作中,Javadoc 的输出中没有包含同步修饰符,这是有原因的。方法声明中 synchronized 修饰符的存在是实现细节,而不是其 API 的一部分。它不能可靠地表明方法是线程安全的。

此外,声称 synchronized 修饰符的存在就足以记录线程安全性,这个观点是对线程安全性属性的误解,认为要么全有要么全无。实际上,线程安全有几个级别。要启用安全的并发使用,类必须清楚地记录它支持的线程安全级别。 下面的列表总结了线程安全级别。它并非详尽无遗,但涵盖以下常见情况:

  • 不可变的 — 这个类的实例看起来是常量。不需要外部同步。示例包括 StringLongBigInteger(详见第 17 条)。
  • 无条件线程安全 — 该类的实例是可变的,但是该类具有足够的内部同步,因此无需任何外部同步即可并发地使用该类的实例。例如 AtomicLongConcurrentHashMap
  • 有条件的线程安全 — 与无条件线程安全类似,只是有些方法需要外部同步才能安全并发使用。示例包括 Collections.synchronized 包装器返回的集合,其迭代器需要外部同步。
  • 非线程安全 — 该类的实例是可变的。要并发地使用它们,客户端必须使用外部同步来包围每个方法调用(或调用序列)。这样的例子包括通用的集合实现,例如 ArrayListHashMap
  • 线程对立 — 即使每个方法调用都被外部同步包围,该类对于并发使用也是不安全的。线程对立通常是由于在不同步的情况下修改静态数据而导致的。没有人故意编写线程对立类;此类通常是由于没有考虑并发性而导致的。当发现类或方法与线程不相容时,通常将其修复或弃用。第 78 条中的 generateSerialNumber 方法在没有内部同步的情况下是线程对立的,如第 322 页所述。

这些类别(不包括线程对立类)大致对应于《Java Concurrency in Practice》中的线程安全注解,分别为 ImmutableThreadSafeNotThreadSafe [Goetz06, Appendix A]。上面分类中的无条件线程安全和有条件的线程安全都包含在 ThreadSafe 注解中。

在文档中记录一个有条件的线程安全类需要小心。你必须指出哪些调用序列需要外部同步,以及执行这些序列必须获得哪些锁(在极少数情况下是锁)。通常是实例本身的锁,但也有例外。例如,Collections.synchronizedMap 的文档提到:

It is imperative that the user manually synchronize on the returned map when iterating over any of its collection views:

当用户遍历其集合视图时,必须手动同步返回的 Map:

1
2
3
4
5
6
7
Map<K, V> m = Collections.synchronizedMap(new HashMap<>());
Set<K> s = m.keySet(); // Needn't be in synchronized block
...
synchronized(m) { // Synchronizing on m, not s!
for (K key : s)
key.f();
}

不遵循这个建议可能会导致不确定的行为。

类的线程安全的描述通常属于该类的文档注释,但是具有特殊线程安全属性的方法应该在它们自己的文档注释中描述这些属性。没有必要记录枚举类型的不变性。除非从返回类型可以明显看出,否则静态工厂必须记录返回对象的线程安全性,正如 Collections.synchronizedMap 所演示的那样。

当一个类使用公共可访问锁时,它允许客户端自动执行一系列方法调用,但是这种灵活性是有代价的。它与诸如 ConcurrentHashMap 之类的并发集合所使用的高性能内部并发控制不兼容。此外,客户端可以通过长时间持有可公开访问的锁来发起拒绝服务攻击。这可以是无意的,也可以是有意的。

为了防止这种拒绝服务攻击,你可以使用一个私有锁对象,而不是使用同步方法(隐含一个公共可访问的锁):

因为私有锁对象在类之外是不可访问的,所以客户端不可能干扰对象的同步。实际上,我们通过将锁对象封装在它同步的对象中,是在应用条目 15 的建议。

注意,lock 字段被声明为 final。这可以防止你无意中更改它的内容,这可能导致灾难性的非同步访问(详见 78 条)。我们正在应用条目 17 的建议,最小化锁字段的可变性。Lock 字段应该始终声明为 final。 无论使用普通的监视器锁(如上所示)还是 java.util.concurrent 包中的锁,都是这样。

私有锁对象用法只能在无条件的线程安全类上使用。有条件的线程安全类不能使用这种用法,因为它们必须在文档中记录,在执行某些方法调用序列时要获取哪些锁。

私有锁对象用法特别适合为继承而设计的类(详见第 19 条)。如果这样一个类要使用它的实例进行锁定,那么子类很容易在无意中干扰基类的操作,反之亦然。通过为不同的目的使用相同的锁,子类和基类最终可能「踩到对方的脚趾头」。这不仅仅是一个理论问题,它就发生在 Thread 类中 [Bloch05, Puzzle 77]。

总之,每个类都应该措辞严谨的描述或使用线程安全注解清楚地记录其线程安全属性。synchronized 修饰符在文档中没有任何作用。有条件的线程安全类必须记录哪些方法调用序列需要外部同步,以及在执行这些序列时需要获取哪些锁。如果你编写一个无条件线程安全的类,请考虑使用一个私有锁对象来代替同步方法。这将保护你免受客户端和子类的同步干扰,并为你提供更大的灵活性,以便在后续的版本中采用复杂的并发控制方式。

29. 优先考虑泛型

29. 优先考虑泛型

参数化声明并使用 JDK 提供的泛型类型和方法通常不会太困难。 但编写自己的泛型类型有点困难,但值得努力学习。

考虑条目 7 中的简单堆栈实现:

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
// Object-based collection - a prime candidate for generics
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}

public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}

public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}

public boolean isEmpty() {
return size == 0;
}

private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}

这个类应该已经被参数化了,但是由于事实并非如此,我们可以对它进行泛型化。 换句话说,我们可以参数化它,而不会损害原始非参数化版本的客户端。 就目前而言,客户端必须强制转换从堆栈中弹出的对象,而这些强制转换可能会在运行时失败。 泛型化类的第一步是在其声明中添加一个或多个类型参数。 在这种情况下,有一个类型参数,表示堆栈的元素类型,这个类型参数的常规名称是 E(详见第 68 条)。

下一步是用相应的类型参数替换所有使用的 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
// Initial attempt to generify Stack - won't compile!
public class Stack<E> {
private E[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;

public Stack() {
elements = new E[DEFAULT_INITIAL_CAPACITY];
}

public void push(E e) {
ensureCapacity();
elements[size++] = e;
}

public E pop() {
if (size == 0)
throw new EmptyStackException();
E result = elements[--size];
elements[size] = null; // Eliminate obsolete reference
return result;
}
... // no changes in isEmpty or ensureCapacity
}

你通常会得到至少一个错误或警告,这个类也不例外。 幸运的是,这个类只产生一个错误:

1
2
3
Stack.java:8: generic array creation
elements = new E[DEFAULT_INITIAL_CAPACITY];
^

如条目 28 所述,你不能创建一个不可具体化类型的数组,例如类型 E。每当编写一个由数组支持的泛型时,就会出现此问题。 有两种合理的方法来解决它。 第一种解决方案直接规避了对泛型数组创建的禁用:创建一个 Object 数组并将其转换为泛型数组类型。 现在没有了错误,编译器会发出警告。 这种用法是合法的,但不是(一般)类型安全的:

1
2
3
4
Stack.java:8: warning: [unchecked] unchecked cast
found: Object[], required: E[]
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
^

编译器可能无法证明你的程序是类型安全的,但你可以。 你必须说服自己,不加限制的类型强制转换不会损害程序的类型安全。 有问题的数组(元素)保存在一个私有属性中,永远不会返回给客户端或传递给任何其他方法。 保存在数组中的唯一元素是那些传递给 push 方法的元素,它们是 E 类型的,所以未经检查的强制转换不会造成任何伤害。

一旦证明未经检查的强制转换是安全的,请尽可能缩小范围(条目 27)。 在这种情况下,构造方法只包含未经检查的数组创建,所以在整个构造方法中抑制警告是合适的。 通过添加一个注解来执行此操作,Stack 可以干净地编译,并且可以在没有显式强制转换或担心 ClassCastException 异常的情况下使用它:

1
2
3
4
5
6
7
// The elements array will contain only E instances from push(E).
// This is sufficient to ensure type safety, but the runtime
// type of the array won't be E[]; it will always be Object[]!
@SuppressWarnings("unchecked")
public Stack() {
elements = (E[]) new Object[DEFAULT_INITIAL_CAPACITY];
}

消除 Stack 中的泛型数组创建错误的第二种方法是将属性元素的类型从 E[] 更改为 Object[]。 如果这样做,会得到一个不同的错误:

1
2
3
4
Stack.java:19: incompatible types
found: Object, required: E
E result = elements[--size];
^

可以通过将从数组中检索到的元素转换为 E 来将此错误更改为警告:

1
2
3
4
Stack.java:19: warning: [unchecked] unchecked cast
found: Object, required: E
E result = (E) elements[--size];
^

因为 E 是不可具体化的类型,编译器无法在运行时检查强制转换。 再一次,你可以很容易地向自己证明,不加限制的转换是安全的,所以可以适当地抑制警告。 根据条目 27 的建议,我们只在包含未经检查的强制转换的分配上抑制警告,而不是在整个 pop 方法上:

1
2
3
4
5
6
7
8
9
10
11
12
// Appropriate suppression of unchecked warning
public E pop() {
if (size == 0)
throw new EmptyStackException();

// push requires elements to be of type E, so cast is correct
@SuppressWarnings("unchecked") E result =
(E) elements[--size];

elements[size] = null; // Eliminate obsolete reference
return result;
}

两种消除泛型数组创建的技术都有其追随者。 第一个更可读:数组被声明为 E[] 类型,清楚地表明它只包含 E 实例。 它也更简洁:在一个典型的泛型类中,你从代码中的许多点读取数组;第一种技术只需要一次转换(创建数组的地方),而第二种技术每次读取数组元素都需要单独转换。 因此,第一种技术是优选的并且在实践中更常用。 但是,它确实会造成堆污染(heap pollution)(详见第 32 条):数组的运行时类型与编译时类型不匹配(除非 E 碰巧是 Object)。 这使得一些程序员非常不安,他们选择了第二种技术,尽管在这种情况下堆的污染是无害的。

下面的程序演示了泛型 Stack 类的使用。 该程序以相反的顺序打印其命令行参数,并将其转换为大写。 对从堆栈弹出的元素调用 StringtoUpperCase 方法不需要显式强制转换,而自动生成的强制转换将保证成功:

1
2
3
4
5
6
7
8
// Little program to exercise our generic Stack
public static void main(String[] args) {
Stack<String> stack = new Stack<>();
for (String arg : args)
stack.push(arg);
while (!stack.isEmpty())
System.out.println(stack.pop().toUpperCase());
}

上面的例子似乎与条目 28 相矛盾,条目 28 中鼓励使用列表优先于数组。 在泛型类型中使用列表并不总是可行或可取的。 Java 本身生来并不支持列表,所以一些泛型类型(如 ArrayList)必须在数组上实现。 其他的泛型类型,比如 HashMap,是为了提高性能而实现的。

绝大多数泛型类型就像我们的 Stack 示例一样,它们的类型参数没有限制:可以创建一个 Stack<Object>, Stack<int[]>Stack<List<String>> 或者其他任何对象的 Stack 引用类型。 请注意,不能创建基本类型的堆栈:尝试创建 Stack<int>Stack<double> 将导致编译时错误。 这是 Java 泛型类型系统的一个基本限制。 可以使用基本类型的包装类(详见第 61 条)来解决这个限制。

有一些泛型类型限制了它们类型参数的允许值。 例如,考虑 java.util.concurrent.DelayQueue,它的声明如下所示:

1
class DelayQueue<E extends Delayed> implements BlockingQueue<E>

类型参数列表(<E extends Delayed>)要求实际的类型参数 Ejava.util.concurrent.Delayed 的子类型。 这使得 DelayQueue 实现及其客户端可以利用 DelayQueue 元素上的 Delayed 方法,而不需要显式的转换或 ClassCastException 异常的风险。 类型参数 E 被称为限定类型参数。 请注意,子类型关系被定义为每个类型都是自己的子类型[JLS,4.10],因此创建 DelayQueue<Delayed> 是合法的。

总之,泛型类型比需要在客户端代码中强制转换的类型更安全,更易于使用。 当你设计新的类型时,确保它们可以在没有这种强制转换的情况下使用。 这通常意味着使类型泛型化。 如果你有任何现有的类型,应该是泛型的但实际上却不是,那么把它们泛型化。 这使这些类型的新用户的使用更容易,而不会破坏现有的客户端(条目 26)。

30. 优先使用泛型方法

30. 优先使用泛型方法

正如类可以是泛型的,方法也可以是泛型的。 对参数化类型进行操作的静态工具方法通常都是泛型的。 集合中的所有“算法”方法(如 binarySearchsort)都是泛型的。

编写泛型方法类似于编写泛型类型。 考虑这个方法,它返回两个集合的并集:

1
2
3
4
5
6
// Uses raw types - unacceptable! [Item 26]
public static Set union(Set s1, Set s2) {
Set result = new HashSet(s1);
result.addAll(s2);
return result;
}

此方法可以编译但有两个警告:

1
2
3
4
5
6
7
8
Union.java:5: warning: [unchecked] unchecked call to
HashSet(Collection<? extends E>) as a member of raw type HashSet
Set result = new HashSet(s1);
^
Union.java:6: warning: [unchecked] unchecked call to
addAll(Collection<? extends E>) as a member of raw type Set
result.addAll(s2);
^

要修复这些警告并使方法类型安全,请修改其声明以声明表示三个集合(两个参数和返回值)的元素类型的类型参数,并在整个方法中使用此类型参数。 声明类型参数的类型参数列表位于方法的修饰符和返回类型之间。 在这个例子中,类型参数列表是 <E>,返回类型是 Set<E>。 类型参数的命名约定对于泛型方法和泛型类型是相同的(详见第 29 和 68 条):

1
2
3
4
5
6
7
// Generic method
public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
Set<E> result = new HashSet<>(s1);
result.addAll(s2);
return result;

}

至少对于简单的泛型方法来说,就是这样。 此方法编译时不会生成任何警告,并提供类型安全性和易用性。 这是一个简单的程序来运行该方法。 这个程序不包含强制转换,并且编译时没有错误或警告:

1
2
3
4
5
6
7
// Simple program to exercise generic method
public static void main(String[] args) {
Set<String> guys = Set.of("Tom", "Dick", "Harry");
Set<String> stooges = Set.of("Larry", "Moe", "Curly");
Set<String> aflCio = union(guys, stooges);
System.out.println(aflCio);
}

当运行这个程序时,它会打印[Moe, Tom, Harry, Larry, Curly, Dick](输出中元素的顺序依赖于具体实现。)

union 方法的一个限制是所有三个集合(输入参数和返回值)的类型必须完全相同。 通过使用限定通配符类型( bounded wildcard types)(详见第 31 条),可以使该方法更加灵活。

有时,需要创建一个不可改变但适用于许多不同类型的对象。 因为泛型是通过擦除来实现的(详见第 28 条),所以可以使用单个对象进行所有必需的类型参数化,但是需要编写一个静态工厂方法来重复地为每个请求的类型参数化分配对象。 这种被称为泛型单例工厂(generic singleton factory)的模式,用于函数对象(function objects)(详见第 42 条),比如 Collections.reverseOrder 方法,偶尔也用于 Collections.emptySet 之类的集合。

假设你想写一个恒等方法分配器( identity function dispenser)。 类库提供了 Function.identity 方法,所以没有理由编写你自己的实现(详见第 59 条),但它是有启发性的。 如果每次要求的时候都去创建一个新的恒等方法对象是浪费的,因为它是无状态的。 如果 Java 的泛型被具体化,那么每个类型都需要一个恒等方法,但是由于它们被擦除以后,所以泛型的单例就足够了。 以下是它的实例:

1
2
3
4
5
6
7
// Generic singleton factory pattern
private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;

@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
return (UnaryOperator<T>) IDENTITY_FN;
}

IDENTITY_FN 转换为 (UnaryFunction<T>) 会生成一个未经检查的强制转换警告,因为 UnaryOperator<Object> 对于每个 T 都不是一个 UnaryOperator<T>。但是恒等方法是特殊的:它返回未修改的参数,所以我们知道,使用它作为一个 UnaryFunction<T> 是类型安全的,无论 T 的值是多少。因此,我们可以放心地抑制由这个强制生成的未经检查的强制转换警告。 一旦我们完成了这些,代码编译没有错误或警告。

下面是一个示例程序,它使用我们的泛型单例作为 UnaryOperator<String>UnaryOperator<Number>。 像往常一样,它不包含强制转化,编译时也没有错误和警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Sample program to exercise generic singleton
public static void main(String[] args) {
String[] strings = { "jute", "hemp", "nylon" };
UnaryOperator<String> sameString = identityFunction();

for (String s : strings)
System.out.println(sameString.apply(s));

Number[] numbers = { 1, 2.0, 3L };

UnaryOperator<Number> sameNumber = identityFunction();

for (Number n : numbers)
System.out.println(sameNumber.apply(n));
}

虽然相对较少,类型参数受涉及该类型参数本身的某种表达式限制是允许的。 这就是所谓的递归类型限制(recursive type bound)。 递归类型限制的常见用法与 Comparable 接口有关,它定义了一个类型的自然顺序(详见第 14 条)。 这个接口如下所示:

1
2
3
public interface Comparable<T> {
int compareTo(T o);
}

类型参数 T 定义了实现 Comparable<T> 的类型的元素可以比较的类型。 在实际中,几乎所有类型都只能与自己类型的元素进行比较。 所以,例如,String 类实现了 Comparable<String>Integer 类实现了 Comparable<Integer> 等等。

许多方法采用实现 Comparable 的元素的集合来对其进行排序,在其中进行搜索,计算其最小值或最大值等。 要做到这一点,要求集合中的每一个元素都可以与其中的每一个元素相比,换言之,这个元素是可以相互比较的。 以下是如何表达这一约束:

1
2
// Using a recursive type bound to express mutual comparability
public static <E extends Comparable<E>> E max(Collection<E> c);

限定的类型 <E extends Comparable <E >> 可以理解为「任何可以与自己比较的类型 E」,这或多或少精确地对应于相互可比性的概念。

这里有一个与前面的声明相匹配的方法。它根据其元素的自然顺序来计算集合中的最大值,并编译没有错误或警告:

1
2
3
4
5
6
7
8
9
10
// Returns max value in a collection - uses recursive type bound
public static <E extends Comparable<E>> E max(Collection<E> c) {
if (c.isEmpty())
throw new IllegalArgumentException("Empty collection");
E result = null;
for (E e : c)
if (result == null || e.compareTo(result) > 0)
result = Objects.requireNonNull(e);
return result;
}

请注意,如果列表为空,则此方法将引发 IllegalArgumentException 异常。 更好的选择是返回一个 Optional<E>(详见第 55 条)。

递归类型限制可能变得复杂得多,但幸运的是他们很少这样做。 如果你理解了这个习惯用法,它的通配符变体(详见第 31 条)和模拟的自我类型用法(详见第 2 条),你将能够处理在实践中遇到的大多数递归类型限制。

总之,像泛型类型一样,泛型方法比需要客户端对输入参数和返回值进行显式强制转换的方法更安全,更易于使用。 像类型一样,你应该确保你的方法可以不用强制转换,这通常意味着它们是泛型的。 应该泛型化现有的方法,其使用需要强制转换。 这使得新用户的使用更容易,而不会破坏现有的客户端(详见第 26 条)。

28. 列表优于数组

28. 列表优于数组

数组在两个重要方面与泛型不同。 首先,数组是协变的(covariant)。 这个吓人的单词意味着如果 Sub 是 Super 的子类型,则数组类型 Sub[] 是数组类型 Super[] 的子类型。 相比之下,泛型是不变的(invariant):对于任何两种不同的类型 Type1Type2List<Type1> 既不是 List<Type2> 的子类型也不是父类型。[JLS,4.10; Naftalin07, 2.5]。 你可能认为这意味着泛型是不足的,但可以说是数组缺陷。 这段代码是合法的:

1
2
3
// Fails at runtime!
Object[] objectArray = new Long[1];
objectArray[0] = "I don't fit in"; // Throws ArrayStoreException

但这个不是:

1
2
3
// Won't compile!
List<Object> ol = new ArrayList<Long>(); // Incompatible types
ol.add("I don't fit in");

无论哪种方式,你不能把一个 String 类型放到一个 Long 类型容器中,但是用一个数组,你会发现在运行时产生了一个错误;对于列表,可以在编译时就能发现错误。 当然,你宁愿在编译时找出错误。

数组和泛型之间的第二个主要区别是数组被具体化了(reified)[JLS,4.7]。 这意味着数组在运行时知道并强制执行它们的元素类型。 如前所述,如果尝试将一个 String 放入 Long 数组中,得到一个 ArrayStoreException 异常。 相反,泛型通过擦除(erasure)来实现[JLS,4.6]。 这意味着它们只在编译时执行类型约束,并在运行时丢弃(或擦除)它们的元素类型信息。 擦除是允许泛型类型与不使用泛型的遗留代码自由互操作(详见第 26 条),从而确保在 Java 5 中平滑过渡到泛型。

由于这些基本差异,数组和泛型不能很好地在一起混合使用。 例如,创建泛型类型的数组,参数化类型的数组,以及类型参数的数组都是非法的。 因此,这些数组创建表达式都不合法:new List<E>[]new List<String>[]new E[]。 所有将在编译时导致泛型数组创建错误。

为什么创建一个泛型数组是非法的? 因为它不是类型安全的。 如果这是合法的,编译器生成的强制转换程序在运行时可能会因为 ClassCastException 异常而失败。 这将违反泛型类型系统提供的基本保证。

为了具体说明,请考虑下面的代码片段:

1
2
3
4
5
6
// Why generic array creation is illegal - won't compile!
List<String>[] stringLists = new List<String>[1]; // (1)
List<Integer> intList = List.of(42); // (2)
Object[] objects = stringLists; // (3)
objects[0] = intList; // (4)
String s = stringLists[0].get(0); // (5)

让我们假设第 1 行创建一个泛型数组是合法的。第 2 行创建并初始化包含单个元素的 List<Integer>。第 3 行将 List<String> 数组存储到 Object 数组变量中,这是合法的,因为数组是协变的。第 4 行将 List<Integer> 存储在 Object 数组的唯一元素中,这是因为泛型是通过擦除来实现的:List<Integer> 实例的运行时类型仅仅是 List,而 List<String>[] 实例是 List[],所以这个赋值不会产生 ArrayStoreException 异常。现在我们遇到了麻烦。将一个 List<Integer> 实例存储到一个声明为仅保存 List<String> 实例的数组中。在第 5 行中,我们从这个数组的唯一列表中检索唯一的元素。编译器自动将检索到的元素转换为 String,但它是一个 Integer,所以我们在运行时得到一个 ClassCastException 异常。为了防止发生这种情况,第 1 行(创建一个泛型数组)必须产生一个编译时错误。

类型 EList<E>List<String> 等在技术上被称为不可具体化的类型(nonreifiable types)[JLS,4.7]。 直观地说,不可具体化的类型是其运行时表示包含的信息少于其编译时表示的类型。 由于擦除,可唯一确定的参数化类型是无限定通配符类型,如 List<?>Map<?, ?>(详见第 26 条)。 尽管很少有用,创建无限定通配符类型的数组是合法的。

禁止泛型数组的创建可能会很恼人的。 这意味着,例如,泛型集合通常不可能返回其元素类型的数组(但是参见条目 33 中的部分解决方案)。 这也意味着,当使用可变参数方法(详见第 53 条)和泛型时,会产生令人困惑的警告。 这是因为每次调用可变参数方法时,都会创建一个数组来保存可变参数。 如果此数组的元素类型不可确定,则会收到警告。 SafeVarargs 注解可以用来解决这个问题(详见第 32 条)。

当你在强制转换为数组类型时,得到泛型数组创建错误,或是未经检查的强制转换警告时,最佳解决方案通常是使用集合类型 List<E> 而不是数组类型 E[]。 这样可能会牺牲一些简洁性或性能,但作为交换,你会获得更好的类型安全性和互操作性。

例如,假设你想用带有集合的构造方法来编写一个 Chooser 类,并且有个方法返回随机选择的集合的一个元素。 根据传递给构造方法的集合,可以使用该类的实例对象作为游戏骰子,魔力 8 号球或蒙特卡罗模拟的数据源。 这是一个没有泛型的简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Chooser - a class badly in need of generics!
public class Chooser {
private final Object[] choiceArray;


public Chooser(Collection choices) {
choiceArray = choices.toArray();
}


public Object choose() {
Random rnd = ThreadLocalRandom.current();
return choiceArray[rnd.nextInt(choiceArray.length)];
}
}

要使用这个类,每次调用方法时,都必须将 choose 方法的返回值从 Object 转换为所需的类型,如果类型错误,则转换在运行时失败。 我们先根据条目 29 的建议,试图修改 Chooser 类,使其成为泛型的。

1
2
3
4
5
6
7
8
9
10
// A first cut at making Chooser generic - won't compile
public class Chooser<T> {
private final T[] choiceArray;

public Chooser(Collection<T> choices) {
choiceArray = choices.toArray();
}

// choose method unchanged
}

如果你尝试编译这个类,会得到这个错误信息:

1
2
3
4
5
6
Chooser.java:9: error: incompatible types: Object[] cannot be
converted to T[]
choiceArray = choices.toArray();
^
where T is a type-variable:
T extends Object declared in class Chooser

没什么大不了的,将 Object 数组转换为 T 数组:

1
choiceArray = (T[]) choices.toArray();

这没有了错误,而是得到一个警告:

1
2
3
4
5
6
Chooser.java:9: warning: [unchecked] unchecked cast
choiceArray = (T[]) choices.toArray();
^
required: T[], found: Object[]
where T is a type-variable:
T extends Object declared in class Chooser

编译器告诉你在运行时不能保证强制转换的安全性,因为程序不会知道 T 代表什么类型——记住,元素类型信息在运行时会被泛型删除。 该程序可以正常工作吗? 是的,但编译器不能证明这一点。 你可以证明这一点,在注释中提出证据,并用注解来抑制警告,但最好是消除警告的原因(详见第 27 条)。

要消除未经检查的强制转换警告,请使用列表而不是数组。 下面是另一个版本的 Chooser 类,编译时没有错误或警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// List-based Chooser - typesafe
public class Chooser<T> {
private final List<T> choiceList;


public Chooser(Collection<T> choices) {
choiceList = new ArrayList<>(choices);
}


public T choose() {
Random rnd = ThreadLocalRandom.current();
return choiceList.get(rnd.nextInt(choiceList.size()));
}
}

这个版本有些冗长,也许运行比较慢,但是值得一提的是,在运行时不会得到 ClassCastException 异常。

总之,数组和泛型具有非常不同的类型规则。 数组是协变和具体化的; 泛型是不变的,类型擦除的。 因此,数组提供运行时类型的安全性,但不提供编译时类型的安全性,对于泛型则是相反。 一般来说,数组和泛型不能很好地混合工作。 如果你发现把它们混合在一起,得到编译时错误或者警告,你的第一个冲动应该是用列表来替换数组。

52. 明智审慎地使用重载

52. 明智审慎地使用重载

下面的程序是一个善意的尝试,根据 Set、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
26
// Broken! - What does this program print?
public class CollectionClassifier {

public static String classify(Set<?> s) {
return "Set";
}

public static String classify(List<?> lst) {
return "List";
}

public static String classify(Collection<?> c) {
return "Unknown Collection";
}

public static void main(String[] args) {
Collection<?>[] collections = {
new HashSet<String>(),
new ArrayList<BigInteger>(),
new HashMap<String, String>().values()
};

for (Collection<?> c : collections)
System.out.println(classify(c));
}
}

您可能希望此程序打印 Set,然后是 List 和 Unknown Collection 字符串,实际上并没有。 而是打印了三次 Unknown Collection 字符串。 为什么会这样? 因为classify方法被重载了,在编译时选择要调用哪个重载方法。 对于循环的所有三次迭代,参数的编译时类型是相同的:Collection<?>。 运行时类型在每次迭代中都不同,但这不会影响对重载方法的选择。 因为参数的编译时类型是Collection<?>,所以唯一适用的重载是第三个classify(Collection<?> c)方法,并且在循环的每次迭代中调用这个重载。

此程序的行为是违反直觉的,因为重载(overloaded)方法之间的选择是静态的,而重写(overridden)方法之间的选择是动态的。 根据调用方法的对象的运行时类型,在运行时选择正确版本的重写方法。 作为提醒,当子类包含与父类中具有相同签名的方法声明时,会重写此方法。 如果在子类中重写实例方法并且在子类的实例上调用,则无论子类实例的编译时类型如何,都会执行子类的重写方法。 为了具体说明,请考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Wine {
String name() { return "wine"; }
}

class SparklingWine extends Wine {
@Override String name() { return "sparkling wine"; }
}

class Champagne extends SparklingWine {
@Override String name() { return "champagne"; }
}

public class Overriding {
public static void main(String[] args) {
List<Wine> wineList = List.of(
new Wine(), new SparklingWine(), new Champagne());

for (Wine wine : wineList)
System.out.println(wine.name());
}
}

name方法在Wine类中声明,并在子类SparklingWineChampagne中重写。 正如你所料,此程序打印出 wine,sparkling wine 和 champagne,即使实例的编译时类型在循环的每次迭代中都是Wine。 当调用重写方法时,对象的编译时类型对执行哪个方法没有影响; 总是会执行“最具体 (most specific)”的重写方法。 将此与重载进行比较,其中对象的运行时类型对执行的重载没有影响; 选择是在编译时完成的,完全基于参数的编译时类型。

CollectionClassifier示例中,程序的目的是通过基于参数的运行时类型自动调度到适当的方法重载来辨别参数的类型,就像 Wine 类中的 name 方法一样。 方法重载根本不提供此功能。 假设需要一个静态方法,修复CollectionClassifier程序的最佳方法是用一个执行显式instanceof测试的方法替换classify的所有三个重载:

1
2
3
4
  public static String classify(Collection<?> c) {
return c instanceof Set ? "Set" :
c instanceof List ? "List" : "Unknown Collection";
}

因为重写是规范,而重载是例外,所以重写设置了人们对方法调用行为的期望。 正如CollectionClassifier示例所示,重载很容易混淆这些期望。 编写让程序员感到困惑的代码的行为是不好的实践。 对于 API 尤其如此。 如果 API 的日常用户不知道将为给定的参数集调用多个方法重载中的哪一个,则使用 API 可能会导致错误。 这些错误很可能表现为运行时的不稳定行为,许多程序员很难诊断它们。 因此,应该避免混淆使用重载

究竟是什么构成了重载的混乱用法还有待商榷。一个安全和保守的策略是永远不要导出两个具有相同参数数量的重载。如果一个方法使用了可变参数,除非如第 53 条目所述,保守策略是根本不重载它。如果遵守这些限制,程序员就不会怀疑哪些重载适用于任何一组实际参数。这些限制并不十分繁重,因为总是可以为方法赋予不同的名称,而不是重载它们

例如,考虑ObjectOutputStream类。对于每个基本类型和几个引用类型,它都有其write方法的变体。这些变体都有不同的名称,例如writeBoolean(boolean)writeInt(int)writeLong(long),而不是重载write方法。与重载相比,这种命名模式的另一个好处是,可以为read方法提供相应的名称,例如readBoolean()readInt()readLong()ObjectInputStream类实际上提供了这样的读取方法。

对于构造方法,无法使用不同的名称:类的多个构造函数总是被重载。 在许多情况下,可以选择导出静态工厂而不是构造方法(详见第 1 条)。 此外,使用构造方法,不必担心重载和重写之间的影响,因为构造方法不能被重写。 你可能有机会导出具有相同数量参数的多个构造函数,因此知道如何安全地执行它是值得的。

如果总是清楚哪个重载将应用于任何给定的实际参数集,那么用相同数量的参数导出多个重载不太可能让程序员感到困惑。在这种情况下,每对重载中至少有一个对应的形式参数在这两个重载中具有「完全不同的」类型。如果显然不可能将任何非空表达式强制转换为这两种类型,那么这两种类型是完全不同的。在这些情况下,应用于给定实际参数集的重载完全由参数的运行时类型决定,且不受其编译时类型的影响,因此消除了一个主要的混淆。例如,ArrayList 有一个接受 int 的构造方法和第二个接受 Collection 的构造方法。很难想象在任何情况下,这两个构造方法在调用时哪个会产生混淆。

在 Java 5 之前,所有基本类型都与引用类型完全不同,但在自动装箱存在的情况下,则并非如此,并且它已经造成了真正的麻烦。 考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SetList {
public static void main(String[] args) {
Set<Integer> set = new TreeSet<>();
List<Integer> list = new ArrayList<>();

for (int i = -3; i < 3; i++) {
set.add(i);
list.add(i);
}

for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove(i);
}

System.out.println(set + " " + list);
}
}

首先,程序将从-3 到 2 的整数添加到有序集合和列表中。 然后,它在集合和列表上进行三次相同的remove方法调用。 如果你和大多数人一样,希望程序从集合和列表中删除非负值(0, 1 和 2)并打印[-3, -2, -1] [ - 3, -2, -1]。 实际上,程序从集合中删除非负值,从列表中删除奇数值,并打印[-3, -2, -1] [-2, 0, 2]。 称这种混乱的行为是一种保守的说法。

实际情况是:调用set.remove(i)选择重载remove(E)方法,其中Eset (Integer)的元素类型,将基本类型 i 由 int 自动装箱为 Integer 中。这是你所期望的行为,因此程序最终会从集合中删除正值。另一方面,对list.remove(i)的调用选择重载remove(int i)方法,它将删除列表中指定位置的元素。如果从列表[-3, -2, -1, 0, 1, 2] 开始,移除第 0 个元素,然后是第 1 个,然后是第二个,就只剩下[-2, 0, 2],谜底就解开了。若要修复此问题,请强制转换list.remove的参数为Integer类型,迫使选择正确的重载。或者,也可以调用Integer.valueOf(i),然后将结果传递给list.remove方法。无论哪种方式,程序都会按预期打印[-3, -2, -1][-3, -2, -1]:

1
2
3
4
for (int i = 0; i < 3; i++) {
set.remove(i);
list.remove((Integer) i); // or remove(Integer.valueOf(i))
}

前一个示例所演示的令人混乱的行为是由于List<E>接口对remove方法有两个重载:remove(E)remove(int)。在 Java 5 之前,当 List 接口被“泛型化”时,它有一个remove(Object)方法代替remove(E),而相应的参数类型 Object 和 int 则完全不同。但是,在泛型和自动装箱的存在下,这两种参数类型不再完全不同了。换句话说,在语言中添加泛型和自动装箱破坏了 List 接口。幸运的是,Java 类库中的其他 API 几乎没有受到类似的破坏,但是这个故事清楚地表明,自动装箱和泛型在重载时增加了谨慎的重要性。

在 Java 8 中添加 lambda 表达式和方法引用以后,进一步增加了重载混淆的可能性。 例如,考虑以下两个代码片段:

1
2
3
4
5
new Thread(System.out::println).start();

ExecutorService exec = Executors.newCachedThreadPool();

exec.submit(System.out::println);

虽然 Thread 构造方法调用和submit方法调用看起来很相似,但是前者编译而后者不编译。参数是相同的 (System.out::println),两者都有一个带有Runnable的重载。这里发生了什么?令人惊讶的答案是,submit方法有一个带有Callable <T>参数的重载,而Thread构造方法却没有。你可能认为这不会有什么区别,因为println方法的所有重载都会返回void,因此方法引用不可能是Callable
。这很有道理,但重载解析算法不是这样工作的。也许同样令人惊讶的是,如果println方法没有被重载,那么submit方法调用是合法的。正是被引用的方法(println)的重载和被调用的方法(submit)相结合,阻止了重载解析算法按照你所期望的方式运行。

从技术上讲,问题是System.out::println是一个不精确的方法引用[JLS,15.13.1],并且「包含隐式类型的 lambda 表达式或不精确的方法引用的某些参数表达式被适用性测试忽略,因为在选择目标类型之前无法确定它们的含义[JLS,15.12.2]。」如果你不理解这段话也不要担心; 它针对的是编译器编写者。 关键是在同一参数位置中具有不同功能接口的重载方法或构造方法会导致混淆。 因此,不要在相同参数位置重载采用不同函数式接口的方法。 在此条目的说法中,不同的函数式接口并没有根本不同。 如果传递命令行开关-Xlint:overloads,Java 编译器将警告这种有问题的重载。

数组类型和 Object 以外的类是完全不同的。此外,除了SerializableCloneable之外,数组类型和其他接口类型也完全不同。如果两个不同的类都不是另一个类的后代[JLS, 5.5],则称它们是不相关的。例如,StringThrowable是不相关的。任何对象都不可能是两个不相关类的实例,所以不相关的类也是完全不同的。

还有其他『类型对 (pairs of types)』不能在任何方向转换[JLS, 5.1.12],但是一旦超出上面描述的简单情况,大多数程序员就很难辨别哪些重载 (如果有的话) 适用于一组实际参数。决定选择哪个重载的规则非常复杂,并且随着每个版本的发布而变得越来越复杂。很少有程序员能理解它们所有的微妙之处。

有时候,可能觉得有必要违反这一条目中的指导原则,特别是在演化现有类时。例如,考虑 String,它从 Java 4 开始就有一个contenttequals (StringBuffer)方法。在 Java 5 中,添加了CharSequence接口,来为StringBufferStringBuilderStringCharBuffer和其他类似类型提供公共接口。在添加CharSequence的同时,String 还配备了一个重载的contenttequals方法,该方法接受CharSequence参数。

虽然上面的重载明显违反了此条目中的指导原则,但它不会造成任何危害,因为当在同一个对象引用上调用这两个重载方法时,它们做的是完全相同的事情。程序员可能不知道将调用哪个重载,但只要它们的行为相同,就没有什么后果。确保这种行为的标准方法是,将更具体的重载方法调用转发给更一般的重载方法:

1
2
3
4
// Ensuring that 2 methods have identical behavior by forwarding
public boolean contentEquals(StringBuffer sb) {
return contentEquals((CharSequence) sb);
}

虽然 Java 类库在很大程度上遵循了这一条目中的建议,但是有一些类违反了它。例如,String 导出两个重载的静态工厂方法valueOf(char[])valueOf(Object),它们在传递相同的对象引用时执行完全不同的操作。对此没有任何正当的理由理由,它应该被视为一种异常现象,有可能造成真正的混乱。

总而言之,仅仅可以重载方法并不意味着应该这样做。通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加强制转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。如果做不到这一点,程序员将很难有效地使用重载方法或构造方法,也无法理解为什么它不能工作。

58. for-each 循环优于传统 for 循环

58. for-each 循环优于传统 for 循环

正如在条目 45 中所讨论的,一些任务最好使用 Stream 来完成,一些任务最好使用迭代。下面是一个传统的 for 循环来遍历一个集合:

1
2
3
4
5
// Not the best way to iterate over a collection!
for (Iterator<Element> i = c.iterator(); i.hasNext(); ) {
Element e = i.next();
... // Do something with e
}

下面是迭代数组的传统 for 循环的实例:

1
2
3
4
// Not the best way to iterate over an array!
for (int i = 0; i < a.length; i++) {
... // Do something with a[i]
}

这些习惯用法比 while 循环更好(详见第 57 条),但是它们并不完美。迭代器和索引变量都很混乱——你只需要元素而已。此外,它们也代表了出错的机会。迭代器在每个循环中出现三次,索引变量出现四次,这使你有很多机会使用错误的变量。如果这样做,就不能保证编译器会发现到问题。最后,这两个循环非常不同,引起了对容器类型的不必要注意,并且增加了更改该类型的小麻烦。

for-each 循环(官方称为「增强的 for 语句」)解决了所有这些问题。它通过隐藏迭代器或索引变量来消除混乱和出错的机会。由此产生的习惯用法同样适用于集合和数组,从而简化了将容器的实现类型从一种转换为另一种的过程:

1
2
3
4
// The preferred idiom for iterating over collections and arrays
for (Element e : elements) {
... // Do something with e
}

当看到冒号(:) 时,请将其读作「in」。因此,上面的循环读作「对于元素 elements 中的每个元素 e」。使用 for-each 循环不会降低性能,即使对于数组也是如此:它们生成的代码本质上与手工编写的代码相同。

当涉及到嵌套迭代时,for-each 循环相对于传统 for 循环的优势甚至更大。下面是人们在进行嵌套迭代时经常犯的一个错误:

1
2
3
4
5
6
7
8
9
10
11
12
// Can you spot the bug?
enum Suit { CLUB, DIAMOND, HEART, SPADE }
enum Rank { ACE, DEUCE, THREE, FOUR, FIVE, SIX, SEVEN, EIGHT,
NINE, TEN, JACK, QUEEN, KING }
...
static Collection<Suit> suits = Arrays.asList(Suit.values());
static Collection<Rank> ranks = Arrays.asList(Rank.values());

List<Card> deck = new ArrayList<>();
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); )
for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
deck.add(new Card(i.next(), j.next()));

如果没有发现这个 bug,也不必感到难过。许多专业程序员都曾犯过这样或那样的错误。问题是,对于外部集合(suit),next 方法在迭代器上调用了太多次。它应该从外部循环调用,因此每花色调用一次,但它是从内部循环调用的,因此每一张牌调用一次。在 suit 用完之后,循环抛出 NoSuchElementException 异常。

如果你真的不走运,外部集合的大小是内部集合大小的倍数——也许它们是相同的集合——循环将正常终止,但它不会做你想要的。 例如,考虑这种错误的尝试,打印一对骰子的所有可能的掷法:

1
2
3
4
5
6
7
8
// Same bug, different symptom!
enum Face { ONE, TWO, THREE, FOUR, FIVE, SIX }
...
Collection<Face> faces = EnumSet.allOf(Face.class);

for (Iterator<Face> i = faces.iterator(); i.hasNext(); )
for (Iterator<Face> j = faces.iterator(); j.hasNext(); )
System.out.println(i.next() + " " + j.next());

该程序不会抛出异常,但它只打印 6 个重复的组合(从“ONE ONE”到“SIX SIX”),而不是预期的 36 个组合。

要修复例子中的错误,必须在外部循环的作用域内添加一个变量来保存外部元素:

1
2
3
4
5
6
/ Fixed, but ugly - you can do better!
for (Iterator<Suit> i = suits.iterator(); i.hasNext(); ) {
Suit suit = i.next();
for (Iterator<Rank> j = ranks.iterator(); j.hasNext(); )
deck.add(new Card(suit, j.next()));
}

相反,如果使用嵌套 for-each 循环,问题就会消失。生成的代码也尽可能地简洁:

1
2
3
4
// Preferred idiom for nested iteration on collections and arrays
for (Suit suit : suits)
for (Rank rank : ranks)
deck.add(new Card(suit, rank));

但是,有三种常见的情况是你不能分别使用 for-each 循环的:

  • 有损过滤(Destructive filtering)——如果需要遍历集合,并删除指定选元素,则需要使用显式迭代器,以便可以调用其 remove 方法。 通常可以使用在 Java 8 中添加的 Collection 类中的 removeIf 方法,来避免显式遍历。
  • 转换——如果需要遍历一个列表或数组并替换其元素的部分或全部值,那么需要列表迭代器或数组索引来替换元素的值。
  • 并行迭代——如果需要并行地遍历多个集合,那么需要显式地控制迭代器或索引变量,以便所有迭代器或索引变量都可以同步进行 (正如上面错误的 card 和 dice 示例中无意中演示的那样)。

如果发现自己处于这些情况中的任何一种,请使用传统的 for 循环,并警惕本条目中提到的陷阱。

for-each 循环不仅允许遍历集合和数组,还允许遍历实现 Iterable 接口的任何对象,该接口由单个方法组成。接口定义如下:

1
2
3
4
public interface Iterable<E> {
// Returns an iterator over the elements in this iterable
Iterator<E> iterator();
}

如果必须从头开始编写自己的 Iterator 实现,那么实现 Iterable 会有点棘手,但是如果你正在编写表示一组元素的类型,那么你应该强烈考虑让它实现 Iterable 接口,甚至可以选择不让它实现 Collection 接口。这允许用户使用 for-each 循环遍历类型,他们会永远感激不尽的。

总之,for-each 循环在清晰度,灵活性和错误预防方面提供了超越传统 for 循环的令人注目的优势,而且没有性能损失。 尽可能使用 for-each 循环优先于 for 循环。

0%