杨柳亭

杨柳亭

45. 明智审慎地使用Stream

45. 明智审慎地使用 Stream

在 Java 8 中添加了 Stream API,以简化串行或并行执行批量操作的任务。 该 API 提供了两个关键的抽象:流 (Stream),表示有限或无限的数据元素序列,以及流管道 (stream pipeline),表示对这些元素的多级计算。 Stream 中的元素可以来自任何地方。 常见的源包括集合、数组、文件、正则表达式模式匹配器、伪随机数生成器和其他流。 流中的数据元素可以是对象引用或基本类型。 支持三种基本类型:int,long 和 double。

Stream pipeline 由 Source stream(源流) 的零或多个中间操作(intermediate operations)和一个终结操作( terminal operation)组成。每个中间操作都以某种方式转换流,例如将每个元素映射到该元素的函数,或过滤掉所有不满足某些条件的元素。中间操作都将一个流转换为另一个流,其元素类型可能与输入流相同或不同。终结操作对流执行最后一次中间操作产生的最终计算,例如将其元素存储到集合中、返回某个元素或打印其所有元素。

Stream pipeline 通常是惰性(lazily)计算求值:直到终结操作被调用后才开始计算,而为了完成终结操作而不需要的数据元素永远不会被计算出来。 这种惰性计算求值的方式,使得无限流成为可能。 请注意,没有终结操作的 Stream pipine 是一个静默无操作的指令,所以不要忘记包含一个终止操作。

Stream API 流式的(fluent):它设计允许所有组成 pipeline 的调用被链接到一个表达式中。事实上,多个管道可以链接在一起形成一个表达式。

默认情况下,流管道会按顺序(sequentially)运行。 要使管道并行执行,只需要在管道中的任何流上调用 parallel()方法一样简单,但是通常不建议这么做(详见第 48 条)。

Stream API 具有足够的通用性,实际上任何计算都可以使用 Stream 执行,但是「可以」,并不意味着应该这样做。如果使用得当,流可以使程序更短更清晰;如果使用不当,它们会使程序难以阅读和维护。对于何时使用流没有硬性的规则,但是有一些启发。

考虑以下程序,该程序从字典文件中读取单词并打印其大小符合用户指定的最小值的所有变位词(anagram)组。如果两个单词由长度相通,不同顺序的相同字母组成,则它们是变位词。程序从用户指定的字典文件中读取每个单词并将单词放入 map 对象中。map 对象的键是按照字母排序的单词,因此「staple」的键是「aelpst」,「petals」的键也是「aelpst」:这两个单词就是同位词,所有的同位词共享相同的依字母顺序排列的形式(或称之为 alphagram)。map 对象的值是包含共享字母顺序形式的所有单词的列表。 处理完字典文件后,每个列表都是一个完整的同位词组。然后程序遍历 map 对象的 values() 的视图并打印每个大小符合阈值的列表:

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
// Prints all large anagram groups in a dictionary iteratively
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word),
(unused) -> new TreeSet<>()).add(word);
}
}

for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}

private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}

这个程序中的一个步骤值得注意。将每个单词插入到 map 中(以粗体显示)中使用了 computeIfAbsent 方法,该方法是在 Java 8 中添加的。这个方法在 map 中查找一个键:如果键存在,该方法只返回与其关联的值。如果没有,该方法通过将给定的函数对象应用于键来计算值,将该值与键关联,并返回计算值。computeIfAbsent 方法简化了将多个值与每个键关联的 map 的实现。

现在考虑以下程序,它也能解决同样的问题,但大量过度使用了流。 请注意,整个程序(打开字典文件的代码除外)包含在单个表达式中。 在单独的表达式中打开字典文件的唯一原因是允许使用 try-with-resources 语句,该语句确保关闭字典文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Overuse of streams - don't do this!
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}

如果你发现这段代码难以阅读,不要担心;你不是一个人。它更短,但是可读性也更差,尤其是对于那些不擅长使用流的程序员来说。过度使用流使程序难于阅读和维护。

幸运的是,有一个折中的办法。下面的程序解决了同样的问题,使用流而不过度使用它们。其结果是一个比原来更短更清晰的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Tasteful use of streams enhances clarity and conciseness
public class Anagrams {

public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);

try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
// alphabetize method is the same as in original version
}

即使以前很少接触流,这个程序也不难理解。它在一个 try-with-resources 块中打开字典文件,获得一个由文件中的所有行组成的流。流变量命名为 words,表示流中的每个元素都是一个单词。此流上的管道没有中间操作;它的终结操作将所有单词收集到个 map 对象中,按照字母排列的形式对单词进行分组 (第 46 项)。这与之前两个版本的程序构造的 map 完全相同。然后在 map 的 values() 视图上打开一个新的流 List<String>。当然,这个流中的元素是同位词组。对流进行过滤,以便忽略大小小于 minGroupSize 的所有组,最后由终结操作 forEach 打印剩下的同位词组。

请注意,仔细选择 lambda 参数名称。 上面程序中参数 g 应该真正命名为 group,但是生成的代码行对于本书来说太宽了。 在没有显式类型的情况下,仔细命名 lambda 参数对于流管道的可读性至关重要。

另请注意,单词字母化是在单独的 alphabetize 方法中完成的。 这通过提供操作名称并将实现细节保留在主程序之外来增强可读性。 使用辅助方法对于流管道中的可读性比在迭代代码中更为重要,因为管道缺少显式类型信息和命名临时变量。

字母顺序方法可以使用流重新实现,但基于流的字母顺序方法本来不太清楚,更难以正确编写,并且可能更慢。 这些缺陷是由于 Java 缺乏对原始字符流的支持(这并不意味着 Java 应该支持 char 流;这样做是不可行的)。 要演示使用流处理 char 值的危害,请考虑以下代码:

1
"Hello world!".chars().forEach(System.out::print);

你可能希望它打印 Hello world!,但如果运行它,发现它打印 721011081081113211911111410810033。这是因为 “Hello world!”.chars() 返回的流的元素不是 char 值,而是 int 值,因此调用了 print 的 int 重载。无可否认,一个名为 chars 的方法返回一个 int 值流是令人困惑的。可以通过强制调用正确的重载来修复该程序:

**但理想情况下,应该避免使用流来处理 char 值。 **当开始使用流时,你可能会感到想要将所有循环语句转换为流方式的冲动,但请抵制这种冲动。尽管这是可能的,但可能会损害代码库的可读性和可维护性。 通常,使用流和迭代的某种组合可以最好地完成中等复杂的任务,如上面的 Anagrams 程序所示。 因此,重构现有代码以使用流,并仅在有意义的情况下在新代码中使用它们。

如本项目中的程序所示,流管道使用函数对象 (通常为 lambdas 或方法引用) 表示重复计算,而迭代代码使用代码块表示重复计算。从代码块中可以做一些从函数对象中不能做的事情:

  • 从代码块中,可以读取或修改范围内的任何局部变量; 从 lambda 中,只能读取最终或有效的最终变量[JLS 4.12.4],并且无法修改任何局部变量。
  • 从代码块中,可以从封闭方法返回,中断或继续封闭循环,或抛出声明此方法的任何已检查异常; 从一个 lambda 你不能做这些事情。

如果使用这些技术最好地表达计算,那么它可能不是流的良好匹配。 相反,流可以很容易地做一些事情:

  • 统一转换元素序列
  • 过滤元素序列
  • 使用单个操作组合元素序列 (例如添加、连接或计算最小值)
  • 将元素序列累积到一个集合中,可能通过一些公共属性将它们分组
  • 在元素序列中搜索满足某些条件的元素

如果使用这些技术最好地表达计算,那么使用流是这些场景很好的候选者。

对于流来说,很难做到的一件事是同时访问管道的多个阶段中的相应元素:一旦将值映射到其他值,原始值就会丢失。一种解决方案是将每个值映射到一个包含原始值和新值的 pair 对象,但这不是一个令人满意的解决方案,尤其是在管道的多个阶段需要一对对象时更是如此。生成的代码既混乱又冗长,破坏了流的主要用途。当它适用时,一个更好的解决方案是在需要访问早期阶段值时转换映射。

例如,让我们编写一个程序来打印前 20 个梅森素数 (Mersenne primes)。 梅森素数是一个 2p − 1 形式的数字。如果 p 是素数,相应的梅森数可能是素数; 如果是这样的话,那就是梅森素数。 作为我们管道中的初始流,我们需要所有素数。 这里有一个返回该(无限)流的方法。 我们假设使用静态导入来轻松访问 BigInteger 的静态成员:

1
2
3
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

方法的名称(primes)是一个复数名词,描述了流的元素。 强烈建议所有返回流的方法使用此命名约定,因为它增强了流管道的可读性。 该方法使用静态工厂 Stream.iterate,它接受两个参数:流中的第一个元素,以及从前一个元素生成流中的下一个元素的函数。 这是打印前 20 个梅森素数的程序:

1
2
3
4
5
6
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(System.out::println);
}

这个程序是上面的梅森描述的直接编码:它从素数开始,计算相应的梅森数,过滤掉除素数之外的所有数字(幻数 50 控制概率素性测试 the magic number 50 controls the probabilistic primality test),将得到的流限制为 20 个元素, 并打印出来。

现在假设我们想在每个梅森素数前面加上它的指数 (p),这个值只出现在初始流中,因此在终结操作中不可访问,而终结操作将输出结果。幸运的是通过反转第一个中间操作中发生的映射,可以很容易地计算出 Mersenne 数的指数。 指数是二进制表示中的位数,因此该终结操作会生成所需的结果:

1
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));

有很多任务不清楚是使用流还是迭代。例如,考虑初始化一副新牌的任务。假设 Card 是一个不可变的值类,它封装了 RankSuit,它们都是枚举类型。这个任务代表任何需要计算可以从两个集合中选择的所有元素对。数学家们称它为两个集合的笛卡尔积。下面是一个迭代实现,它有一个嵌套的 for-each 循环,你应该非常熟悉:

1
2
3
4
5
6
7
8
9
10
// Iterative Cartesian product computation
private static List<Card> newDeck() {
List<Card> result = new ArrayList<>();

for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));

return result;
}

下面是一个基于流的实现,它使用了中间操作 flatMap 方法。这个操作将一个流中的每个元素映射到一个流,然后将所有这些新流连接到一个流 (或展平它们)。注意,这个实现包含一个嵌套的 lambda 表达式(rank -> new Card(suit, rank)):

1
2
3
4
5
6
7
8
// Stream-based Cartesian product computation
private static List<Card> newDeck() {
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}

newDeck 的两个版本中哪一个更好? 它归结为个人偏好和你的编程的环境。 第一个版本更简单,也许感觉更自然。 大部分 Java 程序员将能够理解和维护它,但是一些程序员会对第二个(基于流的)版本感觉更舒服。 如果对流和函数式编程有相当的精通,那么它会更简洁,也不会太难理解。 如果不确定自己喜欢哪个版本,则迭代版本可能是更安全的选择。 如果你更喜欢流的版本,并且相信其他使用该代码的程序员会与你共享你的偏好,那么应该使用它。

总之,有些任务最好使用流来完成,有些任务最好使用迭代来完成。将这两种方法结合起来,可以最好地完成许多任务。对于选择使用哪种方法进行任务,没有硬性规定,但是有一些有用的启发式方法。在许多情况下,使用哪种方法将是清楚的;在某些情况下,则不会很清楚。如果不确定一个任务是通过流还是迭代更好地完成,那么尝试这两种方法,看看哪一种效果更好。

31. 使用限定通配符来增加API的灵活性

31. 使用限定通配符来增加 API 的灵活性

如条目 28 所述,参数化类型是不变的。换句话说,对于任何两个不同类型的 Type1Type2List<Type1> 既不是 List<Type2> 的子类型也不是其父类型。尽管 List<String> 不是 List<Object> 的子类型是违反直觉的,但它确实是有道理的。 可以将任何对象放入 List<Object> 中,但是只能将字符串放入 List<String> 中。 由于 List<String> 不能做 List<Object> 所能做的所有事情,所以它不是一个子类型(条目 10 中的里氏替代原则)。

相对于提供的不可变的类型,有时你需要比此更多的灵活性。 考虑条目 29 中的 Stack 类。下面是它的公共 API:

1
2
3
4
5
6
7
8
9
10
11
public class Stack<E> {

public Stack();

public void push(E e);

public E pop();

public boolean isEmpty();

}

假设我们想要添加一个方法来获取一系列元素,并将它们全部推送到栈上。 以下是第一种尝试:

1
2
3
4
5
// pushAll method without wildcard type - deficient!
public void pushAll(Iterable<E> src) {
for (E e : src)
push(e);
}

这种方法可以干净地编译,但不完全令人满意。 如果可遍历的 src 元素类型与栈的元素类型完全匹配,那么它工作正常。 但是,假设有一个 Stack<Number>,并调用 push(intVal),其中 intVal 的类型是 Integer。 这是因为 IntegerNumber 的子类型。 从逻辑上看,这似乎也应该起作用:

1
2
3
Stack<Number> numberStack = new Stack<>();
Iterable<Integer> integers = ... ;
numberStack.pushAll(integers);

但是,如果你尝试了,会得到这个错误消息,因为参数化类型是不变的:

1
2
3
4
StackTest.java:7: error: incompatible types: Iterable<Integer>
cannot be converted to Iterable<Number>
numberStack.pushAll(integers);
^

幸运的是,有对应的解决方法。 该语言提供了一种特殊的参数化类型来调用一个限定通配符类型来处理这种情况。 pushAll 的输入参数的类型不应该是「EIterable 接口」,而应该是「E 的某个子类型的 Iterable 接口」,并且有一个通配符类型,这意味着:Iterable<? extends E>。 (关键字 extends 的使用有点误导:回忆条目 29 中,子类型被定义为每个类型都是它自己的子类型,即使它本身没有继承。)让我们修改 pushAll 来使用这个类型:

1
2
3
4
5
// Wildcard type for a parameter that serves as an E producer
public void pushAll(Iterable<? extends E> src) {
for (E e : src)
push(e);
}

有了这个改变,Stack 类不仅可以干净地编译,而且客户端代码也不会用原始的 pushAll 声明编译。 因为 Stack 和它的客户端干净地编译,你知道一切都是类型安全的。

现在假设你想写一个 popAll 方法,与 pushAll 方法相对应。 popAll 方法从栈中弹出每个元素并将元素添加到给定的集合中。 以下是第一次尝试编写 popAll 方法的过程:

1
2
3
4
5
// popAll method without wildcard type - deficient!
public void popAll(Collection<E> dst) {
while (!isEmpty())
dst.add(pop());
}

同样,如果目标集合的元素类型与栈的元素类型完全匹配,则干净编译并且工作正常。 但是,这又不完全令人满意。 假设你有一个 Stac<Number>Object 类型的变量。 如果从栈中弹出一个元素并将其存储在该变量中,它将编译并运行而不会出错。 你不应该也这样做吗?

1
2
3
4
5
Stack<Number> numberStack = new Stack<Number>();

Collection<Object> objects = ... ;

numberStack.popAll(objects);

如果尝试将此客户端代码与之前显示的 popAll 版本进行编译,则会得到与我们的第一版 pushAll 非常类似的错误:Collection<Object> 不是 Collection<Number> 的子类型。 通配符类型再一次提供了一条出路。 popAll 的输入参数的类型不应该是「E 的集合」,而应该是「E 的某个父类型的集合」(其中父类型被定义为 E 是它自己的父类型[JLS,4.10])。 再次,有一个通配符类型,正是这个意思:Collection<? super E>。 让我们修改 popAll 来使用它:

1
2
3
4
5
// Wildcard type for parameter that serves as an E consumer
public void popAll(Collection<? super E> dst) {
while (!isEmpty())
dst.add(pop());
}

通过这个改动,Stack 类和客户端代码都可以干净地编译。

这个结论很清楚。 为了获得最大的灵活性,对代表生产者或消费者的输入参数使用通配符类型。 如果一个输入参数既是一个生产者又是一个消费者,那么通配符类型对你没有好处:你需要一个精确的类型匹配,这就是没有任何通配符的情况。

这里有一个助记符来帮助你记住使用哪种通配符类型: PECS 代表: producer-extends,consumer-super。

换句话说,如果一个参数化类型代表一个 T 生产者,使用 <? extends T>;如果它代表 T 消费者,则使用 <? super T>。 在我们的 Stack 示例中,pushAll 方法的 src 参数生成栈使用的 E 实例,因此 src 的合适类型为 Iterable<? extends E>popAll 方法的 dst 参数消费 Stack 中的 E 实例,因此 dst 的合适类型是 Collection <? super E>。 PECS 助记符抓住了使用通配符类型的基本原则。 Naftalin 和 Wadler 称之为获取和放置原则(Get and Put Principle)[Naftalin07,2.4]。

记住这个助记符之后,让我们来看看本章中以前项目的一些方法和构造方法声明。 条目 28 中的 Chooser 类构造方法有这样的声明:

1
public Chooser(Collection<T> choices)

这个构造方法只使用集合选择来生产类型 T 的值(并将它们存储起来以备后用),所以它的声明应该使用一个 extends T 的通配符类型。下面是得到的构造方法声明:

1
2
3
// Wildcard type for parameter that serves as an T producer

public Chooser(Collection<? extends T> choices)

这种改变在实践中会有什么不同吗? 是的,会有不同。 假你有一个 List<Integer>,并且想把它传递给 Chooser<Number> 的构造方法。 这不会与原始声明一起编译,但是它只会将限定通配符类型添加到声明中。

现在看看条目 30 中的 union 方法。下是声明:

1
public static <E> Set<E> union(Set<E> s1, Set<E> s2)

两个参数 s1 和 s2 都是 E 的生产者,所以 PECS 助记符告诉我们该声明应该如下:

1
public static <E> Set<E> union(Set<? extends E> s1,  Set<? extends E> s2)

请注意,返回类型仍然是 Set<E>。 不要使用限定通配符类型作为返回类型。除了会为用户提供额外的灵活性,还强制他们在客户端代码中使用通配符类型。 通过修改后的声明,此代码将清晰地编译:

1
2
3
4
5
Set<Integer>  integers =  Set.of(1, 3, 5);

Set<Double> doubles = Set.of(2.0, 4.0, 6.0);

Set<Number> numbers = union(integers, doubles);

如果使用得当,类的用户几乎不会看到通配符类型。 他们使方法接受他们应该接受的参数,拒绝他们应该拒绝的参数。 如果一个类的用户必须考虑通配符类型,那么它的 API 可能有问题。

在 Java 8 之前,类型推断规则不够聪明,无法处理先前的代码片段,这要求编译器使用上下文指定的返回类型(或目标类型)来推断 E 的类型。union 方法调用的目标类型如前所示是 Set<Number>。 如果尝试在早期版本的 Java 中编译片段(以及适合的 Set.of 工厂替代版本),将会看到如此长的错综复杂的错误消息:

1
2
3
4
5
6
7
8
Union.java:14: error: incompatible types
Set<Number> numbers = union(integers, doubles);
^
required: Set<Number>
found: Set<INT#1>
where INT#1,INT#2 are intersection types:
INT#1 extends Number,Comparable<? extends INT#2>
INT#2 extends Number,Comparable<?>

幸运的是有办法来处理这种错误。 如果编译器不能推断出正确的类型,你可以随时告诉它使用什么类型的显式类型参数[JLS,15.12]。 甚至在 Java 8 中引入目标类型之前,这不是你必须经常做的事情,这很好,因为显式类型参数不是很漂亮。 通过添加显式类型参数,如下所示,代码片段在 Java 8 之前的版本中进行了干净编译:

1
2
// Explicit type parameter - required prior to Java 8
Set<Number> numbers = Union.<Number>union(integers, doubles);

接下来让我们把注意力转向条目 30 中的 max 方法。这里是原始声明:

1
public static <T extends Comparable<T>> T max(List<T> list)

为了从原来到修改后的声明,我们两次应用了 PECS。首先直接的应用是参数列表。 它生成 T 实例,所以将类型从 List<T> 更改为 List<? extends T>。 棘手的应用是类型参数 T。这是我们第一次看到通配符应用于类型参数。 最初,T 被指定为继承 Comparable<T>,但 ComparableT 消费 T 实例(并生成指示顺序关系的整数)。 因此,参数化类型 Comparable<T> 被替换为限定通配符类型 Comparable<? super T>Comparable 实例总是消费者,所以通常应该使用 ​​**Comparable<? super T>​ 优于 ​Comparable<T>。** Comparator 也是如此。因此,通常应该使用 ​​**Comparator<? super T>​ 优于 ​Comparator<T>。**

修改后的 max 声明可能是本书中最复杂的方法声明。 增加的复杂性是否真的起作用了吗? 同样,它的确如此。 这是一个列表的简单例子,它被原始声明排除,但在被修改后的版本里是允许的:

1
List<ScheduledFuture<?>> scheduledFutures = ... ;

无法将原始方法声明应用于此列表的原因是 ScheduledFuture 不实现 Comparable<ScheduledFuture>。 相反,它是 Delayed 的子接口,它继承了 Comparable<Delayed>。 换句话说,一个 ScheduledFuture 实例不仅仅和其他的 ScheduledFuture 实例相比较: 它可以与任何 Delayed 实例比较,并且足以导致原始的声明拒绝它。 更普遍地说,通配符要求来支持没有直接实现 Comparable(或 Comparator)的类型,但继承了一个类型。

还有一个关于通配符相关的话题。 类型参数和通配符之间具有双重性,许多方法可以用一个或另一个声明。 例如,下面是两个可能的声明,用于交换列表中两个索引项目的静态方法。 第一个使用无限制类型参数(条目 30),第二个使用无限制通配符:

1
2
3
// Two possible declarations for the swap method
public static <E> void swap(List<E> list, int i, int j);
public static void swap(List<?> list, int i, int j);

这两个声明中的哪一个更可取,为什么? 在公共 API 中,第二个更好,因为它更简单。 你传入一个列表(任何列表),该方法交换索引的元素。 没有类型参数需要担心。 通常, 如果类型参数在方法声明中只出现一次,请将其替换为通配符。 如果它是一个无限制的类型参数,请将其替换为无限制的通配符; 如果它是一个限定类型参数,则用限定通配符替换它。

第二个 swap 方法声明有一个问题。 这个简单的实现不会编译:

1
2
3
public static void swap(List<?> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}

试图编译它会产生这个不太有用的错误信息:

1
2
3
4
5
6
Swap.java:5: error: incompatible types: Object cannot be
converted to CAP#1
list.set(i, list.set(j, list.get(i)));
^
where CAP#1 is a fresh type-variable:
CAP#1 extends Object from capture of ?

看起来我们不能把一个元素放回到我们刚刚拿出来的列表中。 问题是列表的类型是 List<?>,并且不能将除 null 外的任何值放入 List<?> 中。 幸运的是,有一种方法可以在不使用不安全的转换或原始类型的情况下实现此方法。 这个想法是写一个私有辅助方法来捕捉通配符类型。 辅助方法必须是泛型方法才能捕获类型。 以下是它的定义:

1
2
3
4
5
6
7
8
public static void swap(List<?> list, int i, int j) {
swapHelper(list, i, j);
}

// Private helper method for wildcard capture
private static <E> void swapHelper(List<E> list, int i, int j) {
list.set(i, list.set(j, list.get(i)));
}

swapHelper 方法知道该列表是一个 List<E>。 因此,它知道从这个列表中获得的任何值都是 E 类型,并且可以安全地将任何类型的 E 值放入列表中。 这个稍微复杂的 swap 的实现可以干净地编译。 它允许我们导出基于通配符的漂亮声明,同时利用内部更复杂的泛型方法。 swap 方法的客户端不需要面对更复杂的 swapHelper 声明,但他们从中受益。 辅助方法具有我们认为对公共方法来说过于复杂的签名。

总之,在你的 API 中使用通配符类型,虽然棘手,但使得 API 更加灵活。 如果编写一个将被广泛使用的类库,正确使用通配符类型应该被认为是强制性的。 记住基本规则: producer-extends, consumer-super(PECS)。 还要记住,所有 ComparableComparator 都是消费者。

34. 使用枚举类型替代整型常量

Java 支持两种引用类型的特殊用途的系列:一种称为枚举类型的类和一种称为注解类型的接口。 本章讨论使用这些类型系列的最佳实践。

34. 使用枚举类型替代整型常量

枚举是其合法值由一组固定的常量组成的一种类型,例如一年中的季节,太阳系中的行星或一副扑克牌中的花色。 在将枚举类型添加到该语言之前,表示枚举类型的常见模式是声明一组名为 int 的常量,每个类型的成员都有一个常量:

1
2
3
4
5
6
7
8
// The int enum pattern - severely deficient!
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;

public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;。

这种被称为 int 枚举模式的技术有许多缺点。 它没有提供类型安全的方式,也没有提供任何表达力。 如果你将一个 Apple 传递给一个需要 Orange 的方法,那么编译器不会出现警告,还会用 == 运算符比较 AppleOrange,或者更糟糕的是:

1
2
// Tasty citrus flavored applesauce!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

请注意,每个 Apple 常量的名称前缀为 APPLE_,每个 Orange 常量的名称前缀为 ORANGE_。 这是因为 Java 不为 int 枚举组提供名称空间。 当两个 inta 枚举组具有相同的命名常量时,前缀可以防止名称冲突,例如在 ELEMENT_MERCURYPLANET_MERCURY 之间。

使用 int 枚举的程序很脆弱。 因为 int 枚举是编译时常量[JLS,4.12.4],所以它们的 int 值被编译到使用它们的客户端中[JLS,13.1]。 如果与 int 枚举关联的值发生更改,则必须重新编译其客户端。 如果没有,客户仍然会运行,但他们的行为将是不正确的。

没有简单的方法将 int 枚举常量转换为可打印的字符串。 如果你打印这样一个常量或者从调试器中显示出来,你看到的只是一个数字,这不是很有用。 没有可靠的方法来迭代组中的所有 int 枚举常量,甚至无法获得 int 枚举组的大小。

你可能会遇到这种模式的变体,其中使用了字符串常量来代替 int 常量。 这种称为字符串枚举模式的变体更不理想。 尽管它为常量提供了可打印的字符串,但它可以导致初级用户将字符串常量硬编码为客户端代码,而不是使用属性名称。 如果这种硬编码的字符串常量包含书写错误,它将在编译时逃脱检测并导致运行时出现错误。 此外,它可能会导致性能问题,因为它依赖于字符串比较。

幸运的是,Java 提供了一种避免 intString 枚举模式的所有缺点的替代方法,并提供了许多额外的好处。 它是枚举类型[JLS,8.9]。 以下是它最简单的形式:

1
2
public enum Apple  { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }

从表面上看,这些枚举类型可能看起来与其他语言类似,比如 C,C++和 C#,但事实并非如此。 Java 的枚举类型是完整的类,比其他语言中的其他语言更强大,其枚举本质本上是 int 值。

Java 枚举类型背后的基本思想很简单:它们是通过公共静态 final 属性为每个枚举常量导出一个实例的类。 由于没有可访问的构造方法,枚举类型实际上是 final 的。 由于客户既不能创建枚举类型的实例也不能继承它,除了声明的枚举常量外,不能有任何实例。 换句话说,枚举类型是实例控制的(第 6 页)。 它们是单例(详见第 3 条)的泛型化,基本上是单元素的枚举。

枚举提供了编译时类型的安全性。 如果声明一个参数为 Apple 类型,则可以保证传递给该参数的任何非空对象引用是三个有效 Apple 值中的一个。 尝试传递错误类型的值将导致编译时错误,因为会尝试将一个枚举类型的表达式分配给另一个类型的变量,或者使用 == 运算符来比较不同枚举类型的值。

具有相同名称常量的枚举类型可以和平共存,因为每种类型都有其自己的名称空间。 可以在枚举类型中添加或重新排序常量,而无需重新编译其客户端,因为导出常量的属性在枚举类型与其客户端之间提供了一层隔离:常量值不会编译到客户端,因为它们位于 int 枚举模式中。 最后,可以通过调用其 toString 方法将枚举转换为可打印的字符串。

除了纠正 int 枚举的缺陷之外,枚举类型还允许添加任意方法和属性并实现任意接口。 它们提供了所有 Object 方法的高质量实现(第 3 章),它们实现了 Comparable(详见第 14 条)和 Serializable(第 12 章),并针对枚举类型的可任意改变性设计了序列化方式。

那么,为什么你要添加方法或属性到一个枚举类型? 对于初学者,可能想要将数据与其常量关联起来。 例如,我们的 AppleOrange 类型可能会从返回水果颜色的方法或返回水果图像的方法中受益。 还可以使用任何看起来合适的方法来增强枚举类型。 枚举类型可以作为枚举常量的简单集合,并随着时间的推移而演变为全功能抽象。

对于丰富的枚举类型的一个很好的例子,考虑我们太阳系的八颗行星。 每个行星都有质量和半径,从这两个属性可以计算出它的表面重力。 从而在给定物体的质量下,计算出一个物体在行星表面上的重量。 下面是这个枚举类型。 每个枚举常量之后的括号中的数字是传递给其构造方法的参数。 在这种情况下,它们是地球的质量和半径:

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
// Enum type with data and behavior
public enum Planet {
MERCURY(3.302e+23, 2.439e6),
VENUS (4.869e+24, 6.052e6),
EARTH (5.975e+24, 6.378e6),
MARS (6.419e+23, 3.393e6),
JUPITER(1.899e+27, 7.149e7),
SATURN (5.685e+26, 6.027e7),
URANUS (8.683e+25, 2.556e7),
NEPTUNE(1.024e+26, 2.477e7);

private final double mass; // In kilograms
private final double radius; // In meters
private final double surfaceGravity; // In m / s^2
// Universal gravitational constant in m^3 / kg s^2
private static final double G = 6.67300E-11;

// Constructor
Planet(double mass, double radius) {
this.mass = mass;
this.radius = radius;
surfaceGravity = G * mass / (radius * radius);
}

public double mass() { return mass; }
public double radius() { return radius; }
public double surfaceGravity() { return surfaceGravity; }

public double surfaceWeight(double mass) {
return mass * surfaceGravity; // F = ma
}
}

编写一个丰富的枚举类型比如 Planet 很容易。 要将数据与枚举常量相关联,请声明实例属性并编写一个构造方法,构造方法带有数据并将数据保存在属性中。 枚举本质上是不变的,所以所有的属性都应该是 final 的(详见第 17 条)。 属性可以是公开的,但最好将它们设置为私有并提供公共访问方法(详见第 16 条)。 在 Planet 的情况下,构造方法还计算和存储表面重力,但这只是一种优化。 每当重力被 SurfaceWeight 方法使用时,它可以从质量和半径重新计算出来,该方法返回它在由常数表示的行星上的重量。

虽然 Planet 枚举很简单,但它的功能非常强大。 这是一个简短的程序,它将一个物体在地球上的重量(任何单位),打印一个漂亮的表格,显示该物体在所有八个行星上的重量(以相同单位):

1
2
3
4
5
6
7
8
9
public class WeightTable {
public static void main(String[] args) {
double earthWeight = Double.parseDouble(args[0]);
double mass = earthWeight / Planet.EARTH.surfaceGravity();
for (Planet p : Planet.values())
System.out.printf("Weight on %s is %f%n",
p, p.surfaceWeight(mass));
}
}

请注意,Planet 和所有枚举一样,都有一个静态 values 方法,该方法以声明的顺序返回其值的数组。 另请注意,toString 方法返回每个枚举值的声明名称,使 printlnprintf 可以轻松打印。 如果你对此字符串表示形式不满意,可以通过重写 toString 方法来更改它。 这是使用命令行参数 185 运行 WeightTable 程序(不重写 toString)的结果:

1
2
3
4
5
6
7
8
Weight on MERCURY is 69.912739
Weight on VENUS is 167.434436
Weight on EARTH is 185.000000
Weight on MARS is 70.226739
Weight on JUPITER is 467.990696
Weight on SATURN is 197.120111
Weight on URANUS is 167.398264
Weight on NEPTUNE is 210.208751

直到 2006 年,在 Java 中加入枚举两年之后,冥王星不再是一颗行星。 这引发了一个问题:「当你从枚举类型中移除一个元素时会发生什么?」答案是,任何不引用移除元素的客户端程序都将继续正常工作。 所以,举例来说,我们的 WeightTable 程序将会打印一个少一行的表格。 那么客户端程序引用删除的元素(在本例中是Planet.Pluto)会如何? 如果重新编译客户端程序,编译将会失败并在引用删除的星球的行处提供有用的错误消息; 如果无法重新编译客户端,它将在运行时从此行中引发有用的异常。 这是你所希望的最好的行为,远远好于你用 int 枚举模式得到的结果。

一些与枚举常量相关的行为只需要在定义枚举的类或包中使用。 这些行为最好以私有或包级私有方式实现。 然后每个常量携带一个隐藏的行为集合,这些行为允许包含枚举的类或包在呈现常量时作出适当的反应。 与其他类一样,除非你有一个令人信服的理由将枚举方法暴露给它的客户端,否则将其声明为私有的,如果需要的话将其声明为包级私有(详见第 15 条)。

如果一个枚举是广泛使用的,它应该是一个顶级类; 如果它的使用与特定的顶级类绑定,它应该是该顶级类的成员类(详见第 24 条)。 例如,java.math.RoundingMode 枚举表示小数部分的舍入模式。 BigDecimal 类使用了这些舍入模式,但它们提供了一种有用的抽象,它并不与 BigDecimal 有根本的联系。 通过将 RoundingMode 设置为顶层枚举,类库设计人员鼓励任何需要舍入模式的程序员重用此枚举,从而提高跨 API 的一致性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Enum type that switches on its own value - questionable
public enum Operation {
PLUS, MINUS, TIMES, DIVIDE;

// Do the arithmetic operation represented by this constant
public double apply(double x, double y) {
switch(this) {
case PLUS: return x + y;
case MINUS: return x - y;
case TIMES: return x * y;
case DIVIDE: return x / y;
}
throw new AssertionError("Unknown op: " + this);
}
}

此代码有效,但不是很漂亮。 如果没有 throw 语句,就不能编译,因为该方法的结束在技术上是可达到的,尽管它永远不会被达到[JLS,14.21]。 更糟的是,代码很脆弱。 如果添加新的枚举常量,但忘记向 switch 语句添加相应的条件,枚举仍然会编译,但在尝试应用新操作时,它将在运行时失败。

幸运的是,有一种更好的方法可以将不同的行为与每个枚举常量关联起来:在枚举类型中声明一个抽象的 apply 方法,并用常量特定的类主体中的每个常量的具体方法重写它。 这种方法被称为特定于常量(constant-specific)的方法实现:

1
2
3
4
5
6
7
8
9
// Enum type with constant-specific method implementations
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;}};

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

如果向第二个版本的操作添加新的常量,则不太可能会忘记提供 apply 方法,因为该方法紧跟在每个常量声明之后。 万一忘记了,编译器会提醒你,因为枚举类型中的抽象方法必须被所有常量中的具体方法重写。

特定于常量的方法实现可以与特定于常量的数据结合使用。 例如,以下是 Operation 的一个版本,它重写 toString 方法以返回通常与该操作关联的符号:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Enum type with constant-specific class bodies and 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);
}

显示的 toString 实现可以很容易地打印算术表达式,正如这个小程序所展示的那样:

1
2
3
4
5
6
7
public static void main(String[] args) {
double x = Double.parseDouble(args[0]);
double y = Double.parseDouble(args[1]);
for (Operation op : Operation.values())
System.out.printf("%f %s %f = %f%n",
x, op, y, op.apply(x, y));
}

以 2 和 4 作为命令行参数运行此程序会生成以下输出:

1
2
3
4
2.000000 + 4.000000 = 6.000000
2.000000 - 4.000000 = -2.000000
2.000000 * 4.000000 = 8.000000
2.000000 / 4.000000 = 0.500000

枚举类型具有自动生成的 valueOf(String) 方法,该方法将常量名称转换为常量本身。 如果在枚举类型中重写 toString 方法,请考虑编写 fromString 方法将自定义字符串表示法转换回相应的枚举类型。 下面的代码(类型名称被适当地改变)将对任何枚举都有效,只要每个常量具有唯一的字符串表示形式:

1
2
3
4
5
6
7
8
9
// Implementing a fromString method on an enum type
private static final Map<String, Operation> stringToEnum =
Stream.of(values()).collect(
toMap(Object::toString, e -> e));

// Returns Operation for string, if any
public static Optional<Operation> fromString(String symbol) {
return Optional.ofNullable(stringToEnum.get(symbol));
}

请注意,Operation 枚举常量被放在 stringToEnummap 中,它来自于创建枚举常量后运行的静态属性初始化。前面的代码在 values() 方法返回的数组上使用流(第 7 章);在 Java 8 之前,我们创建一个空的 hashMap 并遍历值数组,将字符串到枚举映射插入到 map 中,如果愿意,仍然可以这样做。但请注意,尝试让每个常量都将自己放入来自其构造方法的 map 中不起作用。这会导致编译错误,这是好事,因为如果它是合法的,它会在运行时导致 NullPointerException。除了编译时常量属性(详见第 34 条)之外,枚举构造方法不允许访问枚举的静态属性。此限制是必需的,因为静态属性在枚举构造方法运行时尚未初始化。这种限制的一个特例是枚举常量不能从构造方法中相互访问。

另请注意,fromString 方法返回一个 Optional<String>。 这允许该方法指示传入的字符串不代表有效的操作,并且强制客户端面对这种可能性(详见第 55 条)。

特定于常量的方法实现的一个缺点是它们使得难以在枚举常量之间共享代码。 例如,考虑一个代表工资包中的工作天数的枚举。 该枚举有一个方法,根据工人的基本工资(每小时)和当天工作的分钟数计算当天工人的工资。 在五个工作日内,任何超过正常工作时间的工作都会产生加班费; 在两个周末的日子里,所有工作都会产生加班费。 使用 switch 语句,通过将多个 case 标签应用于两个代码片段中的每一个,可以轻松完成此计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Enum that switches on its value to share code - questionable
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY, SUNDAY;

private static final int MINS_PER_SHIFT = 8 * 60;

int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;

int overtimePay;
switch(this) {
case SATURDAY: case SUNDAY: // Weekend
overtimePay = basePay / 2;
break;
default: // Weekday
overtimePay = minutesWorked <= MINS_PER_SHIFT ?
0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
}

return basePay + overtimePay;
}
}

这段代码无可否认是简洁的,但从维护的角度来看是危险的。 假设你给枚举添加了一个元素,可能是一个特殊的值来表示一个假期,但忘记在 switch 语句中添加一个相应的 case 条件。 该程序仍然会编译,但付费方法会默默地为工作日支付相同数量的休假日,与普通工作日相同。

要使用特定于常量的方法实现安全地执行工资计算,必须为每个常量重复加班工资计算,或将计算移至两个辅助方法,一个用于工作日,另一个用于周末,并调用适当的辅助方法来自每个常量。 这两种方法都会产生相当数量的样板代码,大大降低了可读性并增加了出错机会。

通过使用执行加班计算的具体方法替换 PayrollDay 上的抽象 overtimePay 方法,可以减少样板。 那么只有周末的日子必须重写该方法。 但是,这与 switch 语句具有相同的缺点:如果在不重写 overtimePay 方法的情况下添加另一天,则会默默继承周日计算方式。

你真正想要的是每次添加枚举常量时被迫选择加班费策略。 幸运的是,有一个很好的方法来实现这一点。 这个想法是将加班费计算移入私有嵌套枚举中,并将此策略枚举的实例传递给 PayrollDay 枚举的构造方法。 然后,PayrollDay 枚举将加班工资计算委托给策略枚举,从而无需在 PayrollDay 中实现 switch 语句或特定于常量的方法实现。 虽然这种模式不如 switch 语句简洁,但它更安全,更灵活:

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
// The strategy enum pattern
enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
SATURDAY(PayType.WEEKEND), SUNDAY(PayType.WEEKEND);

private final PayType payType;

PayrollDay(PayType payType) { this.payType = payType; }
PayrollDay() { this(PayType.WEEKDAY); } // Default

int pay(int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}

// The strategy enum type
private enum PayType {
WEEKDAY {
int overtimePay(int minsWorked, int payRate) {
return minsWorked <= MINS_PER_SHIFT ? 0 :
(minsWorked - MINS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
int overtimePay(int minsWorked, int payRate) {
return minsWorked * payRate / 2;
}
};

abstract int overtimePay(int mins, int payRate);
private static final int MINS_PER_SHIFT = 8 * 60;

int pay(int minsWorked, int payRate) {
int basePay = minsWorked * payRate;
return basePay + overtimePay(minsWorked, payRate);
}
}
}

如果对枚举的 switch 语句不是实现常量特定行为的好选择,那么它们有什么好处呢?枚举类型的 switch 有利于用常量特定的行为增加枚举类型。例如,假设 Operation 枚举不在你的控制之下,你希望它有一个实例方法来返回每个相反的操作。你可以用以下静态方法模拟效果:

1
2
3
4
5
6
7
8
9
10
// Switch on an enum to simulate a missing method
public static Operation inverse(Operation op) {
switch(op) {
case PLUS: return Operation.MINUS;
case MINUS: return Operation.PLUS;
case TIMES: return Operation.DIVIDE;
case DIVIDE: return Operation.TIMES;
default: throw new AssertionError("Unknown op: " + op);
}
}

如果某个方法不属于枚举类型,则还应该在你控制的枚举类型上使用此技术。 该方法可能需要用于某些用途,但通常不足以用于列入枚举类型。

一般而言,枚举通常在性能上与 int 常数相当。 枚举的一个小小的性能缺点是加载和初始化枚举类型存在空间和时间成本,但在实践中不太可能引人注意。

那么你应该什么时候使用枚举呢? 任何时候使用枚举都需要一组常量,这些常量的成员在编译时已知。 当然,这包括“天然枚举类型”,如行星,星期几和棋子。 但是它也包含了其它你已经知道编译时所有可能值的集合,例如菜单上的选项,操作代码和命令行标志。 一个枚举类型中的常量集不需要一直保持不变。 枚举功能是专门设计用于允许二进制兼容的枚举类型的演变。

总之,枚举类型优于 int 常量的优点是令人信服的。 枚举更具可读性,更安全,更强大。 许多枚举不需要显式构造方法或成员,但其他人则可以通过将数据与每个常量关联并提供行为受此数据影响的方法而受益。 使用单一方法关联多个行为可以减少枚举。 在这种相对罕见的情况下,更喜欢使用常量特定的方法来枚举自己的值。 如果一些(但不是全部)枚举常量共享共同行为,请考虑策略枚举模式。

10. 重写equals方法时遵守通用约定

10. 重写 equals 方法时遵守通用约定

虽然 Object 是一个具体的类,但它主要是为继承而设计的。它的所有非 final 方法(equals、hashCode、toString、clone 和 finalize)都有清晰的通用约定( general contracts),因为它们被设计为被子类重写。任何类要重写这些方法时,都有义务去遵从它们的通用约定;如果不这样做,将会阻止其他依赖于约定的类 (例如 HashMap 和 HashSet) 与此类一起正常工作。

本章论述何时以及如何重写 Object 类的非 final 的方法。这一章省略了 finalize 方法,因为它在条目 8 中进行了讨论。Comparable.compareTo 方法虽然不是 Object 中的方法,因为具有很多的相似性,所以也在这里讨论。

重写 equals 方法看起来很简单,但是有很多方式会导致重写出错,其结果可能是可怕的。避免此问题的最简单方法是不覆盖 equals 方法,在这种情况下,类的每个实例只与自身相等。如果满足以下任一下条件,则说明是正确的做法:

  • 每个类的实例都是固有唯一的。 对于像 Thread 这样代表活动实体而不是值的类来说,这是正确的。 Object 提供的 equals 实现对这些类完全是正确的行为。
  • 类不需要提供一个「逻辑相等(logical equality)」的测试功能。例如 java.util.regex.Pattern 可以重写 equals 方法检查两个是否代表完全相同的正则表达式 Pattern 实例,但是设计者并不认为客户需要或希望使用此功能。在这种情况下,从 Object 继承的 equals 实现是最合适的。
  • 父类已经重写了 equals 方法,则父类行为完全适合于该子类。例如,大多数 Set 从 AbstractSet 继承了 equals 实现、List 从 AbstractList 继承了 equals 实现,Map 从 AbstractMap 的 Map 继承了 equals 实现。
  • 类是私有的或包级私有的,可以确定它的 equals 方法永远不会被调用。如果你非常厌恶风险,可以重写 equals 方法,以确保不会被意外调用:
1
2
3
4
@Override
public boolean equals(Object o) {
throw new AssertionError(); // Method is never called
}

什么时候需要重写 equals 方法呢?如果一个类包含一个逻辑相等(logical equality)的概念,此概念有别于对象标识(object identity),而且父类还没有重写过 equals 方法。这通常用在值类(value classes)的情况。值类只是一个表示值的类,例如 Integer 或 String 类。程序员使用 equals 方法比较值对象的引用,期望发现它们在逻辑上是否相等,而不是引用相同的对象。重写 equals 方法不仅可以满足程序员的期望,它还支持重写过 equals 的实例作为 Map 的键(key),或者 Set 里的元素,以满足预期和期望的行为。

一种不需要 equals 方法重写的值类是使用实例控制(instance control)(详见第 1 条)的类,以确保每个值至多存在一个对象。 枚举类型(详见第 34 条)属于这个类别。 对于这些类,逻辑相等与对象标识是一样的,所以 Object 的 equals 方法作用逻辑 equals 方法。

当你重写 equals 方法时,必须遵守它的通用约定。Object 的规范如下:
equals 方法实现了一个等价关系(equivalence relation)。它有以下这些属性:

  • 自反性: 对于任何非空引用 x,x.equals(x) 必须返回 true。
  • 对称性: 对于任何非空引用 x 和 y,如果且仅当 y.equals(x) 返回 true 时 x.equals(y) 必须返回 true。
  • 传递性: 对于任何非空引用 x、y、z,如果 x.equals(y) 返回 true,y.equals(z) 返回 true,则 x.equals(z) 必须返回 true。
  • 一致性: 对于任何非空引用 x 和 y,如果在 equals 比较中使用的信息没有修改,则 x.equals(y) 的多次调用必须始终返回 true 或始终返回 false。
  • 对于任何非空引用 x,x.equals(null) 必须返回 false。

除非你喜欢数学,否则这看起来有点吓人,但不要忽略它!如果一旦违反了它,很可能会发现你的程序运行异常或崩溃,并且很难确定失败的根源。套用约翰·多恩(John Donne)的说法,没有哪个类是孤立存在的。一个类的实例常常被传递给另一个类的实例。许多类,包括所有的集合类,都依赖于传递给它们遵守 equals 约定的对象。

既然已经意识到违反 equals 约定的危险,让我们详细地讨论一下这个约定。好消息是,表面上看,这并不是很复杂。一旦你理解了,就不难遵守这一约定。

那么什么是等价关系? 笼统地说,它是一个运算符,它将一组元素划分为彼此元素相等的子集。 这些子集被称为等价类(equivalence classes)。 为了使 equals 方法有用,每个等价类中的所有元素必须从用户的角度来说是可以互换(interchangeable)的。 现在让我们依次看下这个五个要求:

自反性(Reflexivity)——第一个要求只是说一个对象必须与自身相等。 很难想象无意中违反了这个规定。 如果你违反了它,然后把类的实例添加到一个集合中,那么 contains 方法可能会说集合中没有包含刚添加的实例。

对称性(Symmetry)——第二个要求是,任何两个对象必须在是否相等的问题上达成一致。与第一个要求不同的是,我们不难想象在无意中违反了这一要求。例如,考虑下面的类,它实现了不区分大小写的字符串。字符串被 toString 保存,但在 equals 比较中被忽略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.Objects;

public final class CaseInsensitiveString {
private final String s;

public CaseInsensitiveString(String s) {
this.s = Objects.requireNonNull(s);
}

// Broken - violates symmetry!
@Override
public boolean equals(Object o) {
if (o instanceof CaseInsensitiveString)
return s.equalsIgnoreCase(
((CaseInsensitiveString) o).s);
if (o instanceof String) // One-way interoperability!
return s.equalsIgnoreCase((String) o);
return false;
}
...// Remainder omitted
}

上面类中的 equals 试图与正常的字符串进行操作,假设我们有一个不区分大小写的字符串和一个正常的字符串:

1
2
3
4
5
CaseInsensitiveString cis = new CaseInsensitiveString("Polish");
String s = "polish";

System.out.println(cis.equals(s)); // true
System.out.println(s.equals(cis)); // false

正如所料,cis.equals(s) 返回 true。 问题是,尽管 CaseInsensitiveString 类中的 equals 方法知道正常字符串,但 String 类中的 equals 方法却忽略了不区分大小写的字符串。 因此,s.equals(cis) 返回 false,明显违反对称性。 假设把一个不区分大小写的字符串放入一个集合中:

1
2
List<CaseInsensitiveString> list = new ArrayList<>();
list.add(cis);

list.contains(s) 返回了什么?谁知道呢?在当前的 OpenJDK 实现中,它会返回 false,但这只是一个实现构件。在另一个实现中,它可以很容易地返回 true 或抛出运行时异常。一旦违反了 equals 约定,就不知道其他对象在面对你的对象时会如何表现了。

要消除这个问题,只需删除 equals 方法中与 String 类相互操作的恶意尝试。这样做之后,可以将该方法重构为单个返回语句:

1
2
3
4
5
@Override
public boolean equals(Object o) {
return o instanceof CaseInsensitiveString &&
((CaseInsensitiveString) o).s.equalsIgnoreCase(s);
}

传递性(Transitivity)—— equals 约定的第三个要求是,如果第一个对象等于第二个对象,第二个对象等于第三个对象,那么第一个对象必须等于第三个对象。同样,也不难想象,无意中违反了这一要求。考虑子类的情况, 将新值组件(value component)添加到其父类中。换句话说,子类添加了一个信息,它影响了 equals 方法比较。让我们从一个简单不可变的二维整数类型 Point 类开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Point {
private final int x;
private final int y;

public Point(int x, int y) {
this.x = x;
this.y = y;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

... // Remainder omitted
}

假设想继承这个类,将表示颜色的 Color 类添加到 Point 类中:

1
2
3
4
5
6
7
8
9
10
public class ColorPoint extends Point {
private final Color color;

public ColorPoint(int x, int y, Color color) {
super(x, y);
this.color = color;
}

... // Remainder omitted
}

equals 方法应该是什么样子?如果完全忽略,则实现是从 Point 类上继承的,颜色信息在 equals 方法比较中被忽略。虽然这并不违反 equals 约定,但这显然是不可接受的。假设你写了一个 equals 方法,它只在它的参数是另一个具有相同位置和颜色的 ColorPoint 实例时返回 true:

1
2
3
4
5
6
7
// Broken - violates symmetry!
@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
return super.equals(o) && ((ColorPoint) o).color == color;
}

当你比较 Point 对象和 ColorPoint 对象时,可以会得到不同的结果,反之亦然。前者的比较忽略了颜色属性,而后者的比较会一直返回 false,因为参数的类型是错误的。为了让问题更加具体,我们创建一个 Point 对象和 ColorPoint 对象:

1
2
Point p = new Point(1, 2);
ColorPoint cp = new ColorPoint(1, 2, Color.RED);

p.equals(cp) 返回 true,但是 cp.equals(p) 返回 false。你可能想使用 ColorPoint.equals 通过混合比较的方式来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public boolean equals(Object o) {
if (!(o instanceof Point))
return false;

// If o is a normal Point, do a color-blind comparison
if (!(o instanceof ColorPoint))
return o.equals(this);

// o is a ColorPoint; do a full comparison
return super.equals(o) && ((ColorPoint) o).color == color;
}

这种方法确实提供了对称性,但是丧失了传递性:

1
2
3
ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
Point p2 = new Point(1, 2);
ColorPoint p3 = new ColorPoint(1, 2, Color.BLUE);

现在,p1.equals(p2)p2.equals(p3) 返回了 true,但是 p1.equals(p3) 却返回了 false,很明显违背了传递性的要求。前两个比较都是不考虑颜色信息的,而第三个比较时却包含颜色信息。

此外,这种方法可能导致无限递归:假设有两个 Point 的子类,比如 ColorPoint 和 SmellPoint,每个都有这种 equals 方法。 然后调用 myColorPoint.equals(mySmellPoint) 将抛出一个 StackOverflowError 异常。

那么解决方案是什么? 事实证明,这是面向对象语言中关于等价关系的一个基本问题。 除非您愿意放弃面向对象抽象的好处,否则无法继承可实例化的类,并在保留 equals 约定的同时添加一个值组件。

你可能听说过,可以继承一个可实例化的类并添加一个值组件,同时通过在 equals 方法中使用一个 getClass 测试代替 instanceof 测试来保留 equals 约定:

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
if (o == null || o.getClass() != getClass())
return false;
Point p = (Point) o;
return p.x == x && p.y == y;
}

只有当对象具有相同的实现类时,才会产生相同的效果。这看起来可能不是那么糟糕,但是结果是不可接受的:一个 Point 类子类的实例仍然是一个 Point 的实例,它仍然需要作为一个 Point 来运行,但是如果你采用这个方法,就会失败!假设我们要写一个方法来判断一个 Point 对象是否在 unitCircle 集合中。我们可以这样做:

1
2
3
4
5
6
7
private static final Set<Point> unitCircle = Set.of(
new Point( 1, 0), new Point( 0, 1),
new Point(-1, 0), new Point( 0, -1));

public static boolean onUnitCircle(Point p) {
return unitCircle.contains(p);
}

虽然这可能不是实现功能的最快方法,但它可以正常工作。假设以一种不添加值组件的简单方式继承 Point 类,比如让它的构造方法跟踪记录创建了多少实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class CounterPoint extends Point {
private static final AtomicInteger counter =
new AtomicInteger();

public CounterPoint(int x, int y) {
super(x, y);
counter.incrementAndGet();
}

public static int numberCreated() {
return counter.get();
}
}

里氏替代原则(Liskov substitution principle)指出,任何类型的重要属性都应该适用于所有的子类型,因此任何为这种类型编写的方法都应该在其子类上同样适用[Liskov87]。 这是我们之前声明的一个正式陈述,即 Point 的子类(如 CounterPoint)仍然是一个 Point,必须作为一个 Point 类来看待。 但是,假设我们将一个 CounterPoint 对象传递给 onUnitCircle 方法。 如果 Point 类使用基于 getClass 的 equals 方法,则无论 CounterPoint 实例的 x 和 y 坐标如何,onUnitCircle 方法都将返回 false。 这是因为大多数集合(包括 onUnitCircle 方法使用的 HashSet)都使用 equals 方法来测试是否包含元素,并且 CounterPoint 实例并不等于任何 Point 实例。 但是,如果在 Point 上使用了适当的基于 instanceof 的 equals 方法,则在使用 CounterPoint 实例呈现时,同样的 onUnitCircle 方法可以正常工作。

虽然没有令人满意的方法来继承一个可实例化的类并添加一个值组件,但是有一个很好的变通方法:按照条目 18 的建议,“优先使用组合而不是继承”。取代继承 Point 类的 ColorPoint 类,可以在 ColorPoint 类中定义一个私有 Point 属性,和一个公共的视图(view)(详见第 6 条)方法,用来返回具有相同位置的 ColorPoint 对象。

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
// Adds a value component without violating the equals contract
public class ColorPoint {
private final Point point;
private final Color color;

public ColorPoint(int x, int y, Color color) {
point = new Point(x, y);
this.color = Objects.requireNonNull(color);
}

/**
* Returns the point-view of this color point.
*/
public Point asPoint() {
return point;
}

@Override
public boolean equals(Object o) {
if (!(o instanceof ColorPoint))
return false;
ColorPoint cp = (ColorPoint) o;
return cp.point.equals(point) && cp.color.equals(color);
}

... // Remainder omitted
}

Java 平台类库中有一些类可以继承可实例化的类并添加一个值组件。 例如,java.sql.Timestamp 继承了 java.util.Date 并添加了一个 nanoseconds 字段。 Timestamp 的等价 equals 确实违反了对称性,并且如果 Timestamp 和 Date 对象在同一个集合中使用,或者以其他方式混合使用,则可能导致不稳定的行为。 Timestamp 类有一个免责声明,告诫程序员不要混用 Timestamp 和 Date。 虽然只要将它们分开使用就不会遇到麻烦,但没有什么可以阻止你将它们混合在一起,并且由此产生的错误可能很难调试。 Timestamp 类的这种行为是一个错误,不应该被仿效。

你可以将值组件添加到抽象类的子类中,而不会违反 equals 约定。这对于通过遵循第 23 个条目中“优先考虑类层级(class hierarchies)来代替标记类(tagged classes)”中的建议而获得的类层级,是非常重要的。例如,可以有一个没有值组件的抽象类 Shape,子类 Circle 有一个 radius 属性,另一个子类 Rectangle 包含 length 和 width 属性 。 只要不直接创建父类实例,就不会出现前面所示的问题。

一致性(Consistent)——equals 约定的第四个要求是,如果两个对象是相等的,除非一个(或两个)对象被修改了, 那么它们必须始终保持相等。 换句话说,可变对象可以在不同时期可以与不同的对象相等,而不可变对象则不会。 当你写一个类时,要认真思考它是否应该设计为不可变的(详见第 17 条)。 如果你认为应该这样做,那么确保你的 equals 方法强制执行这样的限制:相等的对象永远相等,不相等的对象永远都不会相等。

不管一个类是不是不可变的,都不要写一个依赖于不可靠资源的 equals 方法。 如果违反这一禁令,满足一致性要求是非常困难的。 例如,java.net.URL 类中的 equals 方法依赖于与 URL 关联的主机的 IP 地址的比较。 将主机名转换为 IP 地址可能需要访问网络,并且不能保证随着时间的推移会产生相同的结果。 这可能会导致 URL 类的 equals 方法违反 equals 约定,并在实践中造成问题。 URL 类的 equals 方法的行为是一个很大的错误,不应该被效仿。 不幸的是,由于兼容性的要求,它不能改变。 为了避免这种问题,equals 方法应该只对内存驻留对象执行确定性计算。

非空性(Non-nullity)——最后 equals 约定的要求没有官方的名称,所以我冒昧地称之为“非空性”。意思是说说所有的对象都必须不等于 null。虽然很难想象在调用 o.equals(null) 的响应中意外地返回 true,但不难想象不小心抛出 NullPointerException 异常的情况。通用的约定禁止抛出这样的异常。许多类中的 equals 方法都会明确阻止对象为 null 的情况:

1
2
3
4
5
6
@Override
public boolean equals(Object o) {
if (o == null)
return false;
...
}

这个判断是不必要的。 为了测试它的参数是否相等,equals 方法必须首先将其参数转换为合适类型,以便调用访问器或允许访问的属性。 在执行类型转换之前,该方法必须使用 instanceof 运算符来检查其参数是否是正确的类型:

1
2
3
4
5
6
7
@Override
public boolean equals(Object o) {
if (!(o instanceof MyType))
return false;
MyType mt = (MyType) o;
...
}

如果此类型检查漏掉,并且 equals 方法传递了错误类型的参数,那么 equals 方法将抛出 ClassCastException 异常,这违反了 equals 约定。 但是,如果第一个操作数为 null,则指定 instanceof 运算符返回 false,而不管第二个操作数中出现何种类型[JLS,15.20.2]。 因此,如果传入 null,类型检查将返回 false,因此不需要 明确的 null 检查。

综合起来,以下是编写高质量 equals 方法的配方(recipe):

  1. 使用 == 运算符检查参数是否为该对象的引用。如果是,返回 true。这只是一种性能优化,但是如果这种比较可能很昂贵的话,那就值得去做。
  2. 使用 instanceof 运算符来检查参数是否具有正确的类型。 如果不是,则返回 false。 通常,正确的类型是 equals 方法所在的那个类。 有时候,改类实现了一些接口。 如果类实现了一个接口,该接口可以改进 equals 约定以允许实现接口的类进行比较,那么使用接口。 集合接口(如 Set,List,Map 和 Map.Entry)具有此特性。
  3. 参数转换为正确的类型。因为转换操作在 instanceof 中已经处理过,所以它肯定会成功。
  4. 对于类中的每个「重要」的属性,请检查该参数属性是否与该对象对应的属性相匹配。如果所有这些测试成功,返回 true,否则返回 false。如果步骤 2 中的类型是一个接口,那么必须通过接口方法访问参数的属性;如果类型是类,则可以直接访问属性,这取决于属性的访问权限。

对于类型为非 float 或 double 的基本类型,使用 == 运算符进行比较;对于对象引用属性,递归地调用 equals 方法;对于 float 基本类型的属性,使用静态 Float.compare(float, float) 方法;对于 double 基本类型的属性,使用 Double.compare(double, double) 方法。由于存在 Float.NaN-0.0f 和类似的 double 类型的值,所以需要对 float 和 double 属性进行特殊的处理;有关详细信息,请参阅 JLS 15.21.1 或 Float.equals 方法的详细文档。 虽然你可以使用静态方法 Float.equals 和 Double.equals 方法对 float 和 double 基本类型的属性进行比较,这会导致每次比较时发生自动装箱,引发非常差的性能。 对于数组属性,将这些准则应用于每个元素。 如果数组属性中的每个元素都很重要,请使用其中一个重载的 Arrays.equals 方法。

某些对象引用的属性可能合法地包含 null。 为避免出现 NullPointerException 异常,请使用静态方法 Objects.equals(Object, Object) 检查这些属性是否相等。

对于一些类,例如上的 CaseInsensitiveString 类,属性比较相对于简单的相等性测试要复杂得多。在这种情况下,你想要保存属性的一个规范形式(canonical form),这样 equals 方法就可以基于这个规范形式去做开销很小的精确比较,来取代开销很大的非标准比较。这种方式其实最适合不可变类(详见第 17 条)。一旦对象发生改变,一定要确保把对应的规范形式更新到最新。

equals 方法的性能可能受到属性比较顺序的影响。 为了获得最佳性能,你应该首先比较最可能不同的属性,开销比较小的属性,或者最好是两者都满足(derived fields)。 你不要比较不属于对象逻辑状态的属性,例如用于同步操作的 lock 属性。 不需要比较可以从“重要属性”计算出来的派生属性,但是这样做可以提高 equals 方法的性能。 如果派生属性相当于对整个对象的摘要描述,比较这个属性将节省在比较失败时再去比较实际数据的开销。 例如,假设有一个 Polygon 类,并缓存该区域。 如果两个多边形的面积不相等,则不必费心比较它们的边和顶点。

当你完成编写完 equals 方法时,问你自己三个问题:它是对称的吗?它是传递吗?它是一致的吗?除此而外,编写单元测试加以排查,除非使用 AutoValue 框架(第 49 页)来生成 equals 方法,在这种情况下可以安全地省略测试。如果持有的属性失败,找出原因,并相应地修改 equals 方法。当然,equals 方法也必须满足其他两个属性 (自反性和非空性),但这两个属性通常都会满足。

在下面这个简单的 PhoneNumber 类中展示了根据之前的配方构建的 equals 方法:

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
public final class PhoneNumber {

private final short areaCode, prefix, lineNum;

public PhoneNumber(int areaCode, int prefix, int lineNum) {
this.areaCode = rangeCheck(areaCode, 999, "area code");
this.prefix = rangeCheck(prefix, 999, "prefix");
this.lineNum = rangeCheck(lineNum, 9999, "line num");
}

private static short rangeCheck(int val, int max, String arg) {
if (val < 0 || val > max)
throw new IllegalArgumentException(arg + ": " + val);

return (short) val;
}

@Override
public boolean equals(Object o) {
if (o == this)
return true;
if (!(o instanceof PhoneNumber))
return false;

PhoneNumber pn = (PhoneNumber) o;

return pn.lineNum == lineNum && pn.prefix == prefix
&& pn.areaCode == areaCode;
}

... // Remainder omitted
}

以下是一些最后提醒:

  1. 当重写 equals 方法时,同时也要重写 hashCode 方法(详见第 11 条)
  2. 不要让 equals 方法试图太聪明。 如果只是简单地测试用于相等的属性,那么要遵守 equals 约定并不困难。如果你在寻找相等方面过于激进,那么很容易陷入麻烦。一般来说,考虑到任何形式的别名通常是一个坏主意。例如,File 类不应该试图将引用的符号链接等同于同一文件对象。幸好 File 类并没这么做。
  3. 在 equals 方法声明中,不要将参数 Object 替换成其他类型。 对于程序员来说,编写一个如下所示的 equals 方法,然后花上几个小时苦苦思索为什么不能正常工作的情况并不少见:
1
2
3
4
// Broken - parameter type must be Object!
public boolean equals(MyClass o) {

}

问题在于这个方法并没有重写 Object.equals 方法,它的参数是 Object 类型的,这样写只是重载了 equals 方法(详见第 52 条)。 即使除了正常的方法之外,提供这种“强类型”的 equals 方法也是不可接受的,因为它可能会导致子类中的 Override 注解产生误报,提供不安全的错觉。
在这里,使用 Override 注解会阻止你犯这个错误 (详见第 40 条)。这个 equals 方法不会编译,错误消息会告诉你到底错在哪里:

1
2
3
4
5
// Still broken, but won’t compile
@Override
public boolean equals(MyClass o) {

}

编写和测试 equals(和 hashCode)方法很繁琐,生的代码也很普通。替代手动编写和测试这些方法的优雅的手段是,使用谷歌 AutoValue 开源框架,该框架自动为你生成这些方法,只需在类上添加一个注解即可。在大多数情况下,AutoValue 框架生成的方法与你自己编写的方法本质上是相同的。

很多 IDE(例如 Eclipse,NetBeans,IntelliJ IDEA 等)也有生成 equals 和 hashCode 方法的功能,但是生成的源代码比使用 AutoValue 框架的代码更冗长、可读性更差,不会自动跟踪类中的更改,因此需要进行测试。这就是说,使用 IDE 工具生成 equals(和 hashCode) 方法通常比手动编写它们更可取,因为 IDE 工具不会犯粗心大意的错误,而人类则会。

总之,除非必须:在很多情况下,不要重写 equals 方法,从 Object 继承的实现完全是你想要的。 如果你确实重写了 equals 方法,那么一定要比较这个类的所有重要属性,并且以保护前面 equals 约定里五个规定的方式去比较。

0%