Effective Java 3rd 中文版(item:21-25)

21. 为后代设计接口

在Java 8之前,不可能在不破坏现有实现的情况下为接口添加方法。 如果向接口添加了一个新方法,现有的实现通常会缺少该方法,从而导致编译时错误。 在Java 8中,添加了默认方法( default method)构造[JLS 9.4],目的是允许将方法添加到现有的接口。 但是增加新的方法到现有的接口是充满风险的。

默认方法的声明包含一个默认实现,该方法允许实现接口的类直接使用,而不必实现默认方法。 虽然在Java中添加默认方法可以将方法添加到现有接口,但不能保证这些方法可以在所有已有的实现中使用。 默认的方法被“注入(injected)”到现有的实现中,没有经过实现类的知道或同意。 在Java 8之前,这些实现是用默认的接口编写的,它们的接口永远不会获得任何新的方法。

许多新的默认方法被添加到Java 8的核心集合接口中,主要是为了方便使用lambda表达式(第6章)。 Java类库的默认方法是高质量的通用实现,在大多数情况下,它们工作正常。 但是,编写一个默认方法并不总是可能的,它保留了每个可能的实现的所有不变量

例如,考虑在Java 8中添加到Collection接口的removeIf方法。例如,考虑在Java 8中添加到Collection接口的removeIf方法。此方法删除给定布尔方法(或Predicate函数式接口)返回true的所有元素。默认实现被指定为使用迭代器遍历集合,调用每个元素的谓词,并使用迭代器的remove方法删除谓词返回true的元素。 据推测,这个声明看起来像这样:默认实现被指定为使用迭代器遍历集合,调用每个元素的Predicate函数式接口,并使用迭代器的remove方法删除Predicate函数式接口返回true的元素。 根据推测,这个声明看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Default method added to the Collection interface in Java 8

default boolean removeIf(Predicate<? super E> filter) {

Objects.requireNonNull(filter);

boolean result = false;

for (Iterator<E> it = iterator(); it.hasNext(); ) {

if (filter.test(it.next())) {

it.remove();

result = true;

}

}

return result;

}

这是可能为removeIf方法编写的最好的通用实现,但遗憾的是,它在一些实际的Collection实现中失败了。 例如,考虑org.apache.commons.collections4.collection.SynchronizedCollection方法。 这个类出自Apache Commons类库中,与java.util包中的静态工厂Collections.synchronizedCollection方法返回的类相似。 Apache版本还提供了使用客户端提供的对象进行锁定的能力,以代替集合。 换句话说,它是一个包装类(条目 18),它们的所有方法在委托给包装集合类之前在一个锁定对象上进行同步。

Apache的SynchronizedCollection类仍然在积极维护,但在撰写本文时,并未重写removeIf方法。 如果这个类与Java 8一起使用,它将继承removeIf的默认实现,但实际上不能保持类的基本承诺:自动同步每个方法调用。 默认实现对同步一无所知,并且不能访问包含锁定对象的属性。 如果客户端在另一个线程同时修改集合的情况下调用SynchronizedCollection实例上的removeIf方法,则可能会导致ConcurrentModificationException异常或其他未指定的行为。

为了防止在类似的Java平台类库实现中发生这种情况,比如Collections.synchronizedCollection返回的包级私有的类,JDK维护者必须重写默认的removeIf实现和其他类似的方法来在调用默认实现之前执行必要的同步。 原来不属于Java平台的集合实现没有机会与接口更改进行类似的改变,有些还没有这样做。

在默认方法的情况下,接口的现有实现类可以在没有错误或警告的情况下编译,但在运行时会失败。 虽然不是非常普遍,但这个问题也不是一个孤立的事件。 在Java 8中添加到集合接口的一些方法已知是易受影响的,并且已知一些现有的实现会受到影响。

应该避免使用默认方法向现有的接口添加新的方法,除非这个需要是关键的,在这种情况下,你应该仔细考虑,以确定现有的接口实现是否会被默认的方法实现所破坏。然而,默认方法对于在创建接口时提供标准的方法实现非常有用,以减轻实现接口的任务(条目 20)。

还值得注意的是,默认方法不是被用来设计,来支持从接口中移除方法或者改变现有方法的签名的目的。在不破坏现有客户端的情况下,这些接口都不可能发生更改。

准则是清楚的。 尽管默认方法现在是Java平台的一部分,但是非常悉心地设计接口仍然是非常重要的。 虽然默认方法可以将方法添加到现有的接口,但这样做有很大的风险。 如果一个接口包含一个小缺陷,可能会永远惹怒用户。 如果一个接口严重缺陷,可能会破坏包含它的API。

因此,在发布之前测试每个新接口是非常重要的。 多个程序员应该以不同的方式实现每个接口。 至少,你应该准备三种不同的实现。 编写多个使用每个新接口的实例来执行各种任务的客户端程序同样重要。 这将大大确保每个接口都能满足其所有的预期用途。 这些步骤将允许你在发布之前发现接口中的缺陷,但仍然可以轻松地修正它们。 虽然在接口被发布后可能会修正一些存在的缺陷,但不要太指望这一点

22. 接口仅用来定义类型

当类实现接口时,该接口作为一种类型(type),可以用来引用类的实例。因此,一个类实现了一个接口,因此表明客户端可以如何处理类的实例。为其他目的定义接口是不合适的。

一种失败的接口就是所谓的常量接口(constant interface)。 这样的接口不包含任何方法; 它只包含静态final属性,每个输出一个常量。 使用这些常量的类实现接口,以避免需要用类名限定常量名。 这里是一个例子:

1
2
3
4
5
6
7
8
9
10
11
// Constant interface antipattern - do not use!
public interface PhysicalConstants {
// Avogadro's number (1/mol)
static final double AVOGADROS_NUMBER = 6.022_140_857e23;

// Boltzmann constant (J/K)
static final double BOLTZMANN_CONSTANT = 1.380_648_52e-23;

// Mass of the electron (kg)
static final double ELECTRON_MASS = 9.109_383_56e-31;
}

常量接口模式是对接口的糟糕使用。类在内部使用一些常量,完全属于实现细节。实现一个常量接口会导致这个实现细节泄漏到类的导出API中。对类的用户来说,类实现一个常量接口是没有意义的。事实上,它甚至可能使他们感到困惑。更糟糕的是,它代表了一个承诺:如果在将来的版本中修改了类,不再需要使用常量,那么它仍然必须实现接口,以确保二进制兼容性。如果一个非final类实现了常量接口,那么它的所有子类的命名空间都会被接口中的常量所污染。

Java平台类库中有多个常量接口,如java.io.ObjectStreamConstants。 这些接口应该被视为不规范的,不应该被效仿。

如果你想导出常量,有几个合理的选择方案。 如果常量与现有的类或接口紧密相关,则应将其添加到该类或接口中。 例如,所有数字基本类型的包装类,如IntegerDouble,都会导出MIN_VALUEMAX_VALUE常量。 如果常量最好被看作枚举类型的成员,则应该使用枚举类型(条目 34)导出它们。 否则,你应该用一个不可实例化的工具类来导出常量(条目 4)。 下是前面所示的PhysicalConstants示例的工具类的版本:

1
2
3
4
5
6
7
8
9
10
// Constant utility class
package com.effectivejava.science;

public class PhysicalConstants {
private PhysicalConstants() { } // Prevents instantiation

public static final double AVOGADROS_NUMBER = 6.022_140_857e23;
public static final double BOLTZMANN_CONST = 1.380_648_52e-23;
public static final double ELECTRON_MASS = 9.109_383_56e-31;
}

顺便提一下,请注意在数字文字中使用下划线字符(_)。 从Java 7开始,合法的下划线对数字字面量的值没有影响,但是如果使用得当的话可以使它们更容易阅读。 无论是固定的浮点数,如果他们包含五个或更多的连续数字,考虑将下划线添加到数字字面量中。 对于底数为10的数字,无论是整型还是浮点型的,都应该用下划线将数字分成三个数字组,表示一千的正负幂。

通常,实用工具类要求客户端使用类名来限定常量名,例如PhysicalConstants.AVOGADROS_NUMBER如果大量使用实用工具类导出的常量,则通过使用静态导入来限定具有类名的常量

1
2
3
4
5
6
7
8
9
10
// Use of static import to avoid qualifying constants
import static com.effectivejava.science.PhysicalConstants.*;

public class Test {
double atoms(double mols) {
return AVOGADROS_NUMBER * mols;
}
...
// Many more uses of PhysicalConstants justify static import
}

总之,接口只能用于定义类型。 它们不应该仅用于导出常量。

23. 优先使用类层次而不是标签类

有时你可能会碰到一个类,它的实例有两个或更多的风格,并且包含一个标签属性(tag field),表示实例的风格。 例如,考虑这个类,它可以表示一个圆形或矩形:

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
// Tagged class - vastly inferior to a class hierarchy!
class Figure {
enum Shape { RECTANGLE, CIRCLE };

// Tag field - the shape of this figure
final Shape shape;

// These fields are used only if shape is RECTANGLE
double length;
double width;

// This field is used only if shape is CIRCLE
double radius;

// Constructor for circle
Figure(double radius) {
shape = Shape.CIRCLE;
this.radius = radius;
}

// Constructor for rectangle
Figure(double length, double width) {
shape = Shape.RECTANGLE;
this.length = length;
this.width = width;
}

double area() {
switch(shape) {
case RECTANGLE:
return length * width;
case CIRCLE:
return Math.PI * (radius * radius);
default:
throw new AssertionError(shape);
}
}
}

这样的标签类具有许多缺点。 他们杂乱无章的样板代码,包括枚举声明,标签属性和switch语句。 可读性更差,因为多个实现在一个类中混杂在一起。 内存使用增加,因为实例负担属于其他风格不相关的领域。 属性不能成为final,除非构造方法初始化不相关的属性,导致更多的样板代码。 构造方法在编译器的帮助下,必须设置标签属性并初始化正确的数据属性:如果初始化错误的属性,程序将在运行时失败。 除非可以修改其源文件,否则不能将其添加到标记的类中。 如果你添加一个风格,你必须记得给每个switch语句添加一个case,否则这个类将在运行时失败。 最后,一个实例的数据类型没有提供任何关于风格的线索。 总之,标签类是冗长的,容易出错的,而且效率低下

幸运的是,像Java这样的面向对象的语言为定义一个能够表示多种风格对象的单一数据类型提供了更好的选择:子类型化(subtyping)。标签类仅仅是一个类层次的简单的模仿。

要将标签类转换为类层次,首先定义一个包含抽象方法的抽象类,该标签类的行为取决于标签值。 在Figure类中,只有一个这样的方法,就是area方法。 这个抽象类是类层次的根。 如果有任何方法的行为不依赖于标签的值,把它们放在这个类中。 同样,如果有所有的方法使用的数据属性,把它们放在这个类。Figure类中不存在这种与类型无关的方法或属性。

接下来,为原始标签类的每种类型定义一个根类的具体子类。 在我们的例子中,有两个类型:圆形和矩形。 在每个子类中包含特定于改类型的数据字段。 在我们的例子中,半径属性是属于圆的,长度和宽度属性都是矩形的。 还要在每个子类中包含根类中每个抽象方法的适当实现。 这里是对应于Figure类的类层次:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Class hierarchy replacement for a tagged class
abstract class Figure {
abstract double area();
}

class Circle extends Figure {
final double radius;

Circle(double radius) { this.radius = radius; }

@Override double area() { return Math.PI * (radius * radius); }
}
class Rectangle extends Figure {
final double length;
final double width;

Rectangle(double length, double width) {
this.length = length;
this.width = width;
}
@Override double area() { return length * width; }
}

这个类层次纠正了之前提到的标签类的每个缺点。 代码简单明了,不包含原文中的样板文件。 每种类型的实现都是由自己的类来分配的,而这些类都没有被无关的数据属性所占用。 所有的属性是final的。 编译器确保每个类的构造方法初始化其数据属性,并且每个类都有一个针对在根类中声明的每个抽象方法的实现。 这消除了由于缺少switch-case语句而导致的运行时失败的可能性。 多个程序员可以独立地继承类层次,并且可以相互操作,而无需访问根类的源代码。 每种类型都有一个独立的数据类型与之相关联,允许程序员指出变量的类型,并将变量和输入参数限制为特定的类型。

类层次的另一个优点是可以使它们反映类型之间的自然层次关系,从而提高了灵活性,并提高了编译时类型检查的效率。 假设原始示例中的标签类也允许使用正方形。 类层次可以用来反映一个正方形是一种特殊的矩形(假设它们是不可变的):

1
2
3
4
5
lass Square extends Rectangle {
Square(double side) {
super(side, side);
}
}

请注意,上述层中的属性是直接访问的,而不是访问方法。 这里是为了简洁起见,如果类层次是公开的(条目16),这将是一个糟糕的设计。

总之,标签类很少有适用的情况。 如果你想写一个带有明显标签属性的类,请考虑标签属性是否可以被删除,而类是否被类层次替换。 当遇到一个带有标签属性的现有类时,可以考虑将其重构为一个类层次中。

24.优先考虑静态成员类

嵌套类(nested class)是在另一个类中定义的类。 嵌套类应该只存在于其宿主类(enclosing class)中。 如果一个嵌套类在其他一些情况下是有用的,那么它应该是一个顶级类。 有四种嵌套类:静态成员类,非静态成员类,匿名类和局部类。 除了第一种以外,剩下的三种都被称为内部类(inner class)。 这个条目告诉你什么时候使用哪种类型的嵌套类以及为什么使用。

静态成员类是最简单的嵌套类。 最好把它看作是一个普通的类,恰好在另一个类中声明,并且可以访问所有宿主类的成员,甚至是那些被声明为私有类的成员。 静态成员类是其宿主类的静态成员,并遵循与其他静态成员相同的可访问性规则。 如果它被声明为private,则只能在宿主类中访问,等等。

静态成员类的一个常见用途是作为公共帮助类,仅在与其外部类一起使用时才有用。 例如,考虑一个描述计算器支持的操作的枚举类型(条目 34)。 Operation枚举应该是Calculator类的公共静态成员类。 Calculator客户端可以使用Calculator.Operation.PLUSCalculator.Operation.MINUS等名称来引用操作。

在语法上,静态成员类和非静态成员类之间的唯一区别是静态成员类在其声明中具有static修饰符。 尽管句法相似,但这两种嵌套类是非常不同的。 非静态成员类的每个实例都隐含地与其包含的类的宿主实例相关联。 在非静态成员类的实例方法中,可以调用宿主实例上的方法,或者使用限定的构造[JLS,15.8.4]获得对宿主实例的引用。 如果嵌套类的实例可以与其宿主类的实例隔离存在,那么嵌套类必须是静态成员类:不可能在没有宿主实例的情况下创建非静态成员类的实例。

非静态成员类实例和其宿主实例之间的关联是在创建成员类实例时建立的,并且之后不能被修改。 通常情况下,通过在宿主类的实例方法中调用非静态成员类构造方法来自动建立关联。 尽管很少有可能使用表达式enclosingInstance.new MemberClass(args)手动建立关联。 正如你所预料的那样,该关联在非静态成员类实例中占用了空间,并为其构建添加了时间开销。

非静态成员类的一个常见用法是定义一个Adapter [Gamma95],它允许将外部类的实例视为某个不相关类的实例。 例如,Map接口的实现通常使用非静态成员类来实现它们的集合视图,这些视图由Map的keySetentrySetvalues方法返回。 同样,集合接口(如Set和List)的实现通常使用非静态成员类来实现它们的迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
// Typical use of a nonstatic member class
public class MySet<E> extends AbstractSet<E> {
... // Bulk of the class omitted

@Override public Iterator<E> iterator() {
return new MyIterator();
}

private class MyIterator implements Iterator<E> {
...
}
}

如果你声明了一个不需要访问宿主实例的成员类,总是把static修饰符放在它的声明中,使它成为一个静态成员类,而不是非静态的成员类。 如果你忽略了这个修饰符,每个实例都会有一个隐藏的外部引用给它的宿主实例。 如前所述,存储这个引用需要占用时间和空间。 更严重的是,并且会导致即使宿主类在满足垃圾回收的条件时却仍然驻留在内存中(条目 7)。 由此产生的内存泄漏可能是灾难性的。 由于引用是不可见的,所以通常难以检测到。

私有静态成员类的常见用法是表示由它们的宿主类表示的对象的组件。 例如,考虑将键与值相关联的Map实例。 许多Map实现对于映射中的每个键值对都有一个内部的Entry对象。 当每个entry都与Map关联时,entry上的方法(getKeygetValuesetValue)不需要访问Map。 因此,使用非静态成员类来表示entry将是浪费的:私有静态成员类是最好的。 如果意外地忽略了entry声明中的static修饰符,Map仍然可以工作,但是每个entry都会包含对Map的引用,浪费空间和时间。

如果所讨论的类是导出类的公共或受保护成员,则在静态和非静态成员类之间正确选择是非常重要的。 在这种情况下,成员类是导出的API元素,如果不违反向后兼容性,就不能在后续版本中从非静态变为静态成员类。

正如你所期望的,一个匿名类没有名字。 它不是其宿主类的成员。 它不是与其他成员一起声明,而是在使用时同时声明和实例化。 在表达式合法的代码中,匿名类是允许的。 当且仅当它们出现在非静态上下文中时,匿名类才会封装实例。 但是,即使它们出现在静态上下文中,它们也不能有除常量型变量之外的任何静态成员,这些常量型变量包括final的基本类型,或者初始化常量表达式的字符串属性[JLS,4.12.4]。

匿名类的适用性有很多限制。 除了在声明的时候之外,不能实例化它们。 你不能执行instanceof方法测试或者做任何其他需要你命名的类。 不能声明一个匿名类来实现多个接口,或者继承一个类并同时实现一个接口。 匿名类的客户端不能调用除父类型继承的成员以外的任何成员。 因为匿名类在表达式中出现,所以它们必须保持短——约十行或更少——否则可读性将受损。

在将lambda表达式添加到Java(第6章)之前,匿名类是创建小方法对象和处理对象的首选方法,但lambda表达式现在是首选(条目 42)。 匿名类的另一个常见用途是实现静态工厂方法(请参阅条目 20中的intArrayAsList)。

局部类是四种嵌套类中使用最少的。 一个局部类可以在任何可以声明局部变量的地方声明,并遵守相同的作用域规则。 局部类与其他类型的嵌套类具有共同的属性。 像成员类一样,他们有名字,可以重复使用。 就像匿名类一样,只有在非静态上下文中定义它们时,它们才会包含实例,并且它们不能包含静态成员。 像匿名类一样,应该保持简短,以免损害可读性。

回顾一下,有四种不同的嵌套类,每个都有它的用途。 如果一个嵌套的类需要在一个方法之外可见,或者太长而不能很好地适应一个方法,使用一个成员类。 如果一个成员类的每个实例都需要一个对其宿主实例的引用,使其成为非静态的; 否则,使其静态。 假设这个类属于一个方法内部,如果你只需要从一个地方创建实例,并且存在一个预置类型来说明这个类的特征,那么把它作为一个匿名类; 否则,把它变成局部类。

25. 将源文件限制为单个顶级类

虽然Java编译器允许在单个源文件中定义多个顶级类,但这样做没有任何好处,并且存在重大风险。 风险源于在源文件中定义多个顶级类使得为类提供多个定义成为可能。 使用哪个定义会受到源文件传递给编译器的顺序的影响。

为了具体说明,请考虑下面源文件,其中只包含一个引用其他两个顶级类(UtensilDessert类)的成员的Main类:

1
2
3
4
5
6
7
8
9
public class Main {

public static void main(String[] args) {

System.out.println(Utensil.NAME + [Dessert.NAME](http://Dessert.NAME));

}

}

现在假设在Utensil.java的源文件中同时定义了UtensilDessert

1
2
3
4
5
6
7
8
9
10
11
12
13
// Two classes defined in one file. Don't ever do this!

class Utensil {

static final String NAME = "pan";

}

class Dessert {

static final String NAME = "cake";

}

当然,main方法会打印pancake

现在假设你不小心创建了另一个名为Dessert.java的源文件,它定义了相同的两个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Two classes defined in one file. Don't ever do this!

class Utensil {

static final String NAME = "pot";

}

class Dessert {

static final String NAME = "pie";

}

如果你足够幸运,使用命令javac Main.java Dessert.java编译程序,编译将失败,编译器会告诉你,你已经多次定义了类UtensilDessert。 这是因为编译器首先编译Main.java,当它看到对Utensil的引用(它在Dessert的引用之前)时,它将在Utensil.java中查找这个类并找到UtensilDessert。 当编译器在命令行上遇到Dessert.java时,它也将拉入该文件,导致它遇到UtensilDessert的定义。

如果使用命令javac Main.javajavac Main.java Utensil.java编译程序,它的行为与在编写Dessert.java文件(即打印pancake)之前的行为相同。 但是,如果使用命令javac Dessert.java Main.java编译程序,它将打印potpie。 程序的行为因此受到源文件传递给编译器的顺序的影响,这显然是不可接受的。

解决这个问题很简单,将顶层类(如我们的例子中的UtensilDessert)分割成单独的源文件。 如果试图将多个顶级类放入单个源文件中,请考虑使用静态成员类(条目 24)作为将类拆分为单独的源文件的替代方法。 如果这些类从属于另一个类,那么将它们变成静态成员类通常是更好的选择,因为它提高了可读性,并且可以通过声明它们为私有(条目 15)来减少类的可访问性。下面是我们的例子看起来如何使用静态成员类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Static member classes instead of multiple top-level classes

public class Test {

public static void main(String[] args) {

System.out.println(Utensil.NAME + [Dessert.NAME](http://Dessert.NAME));

}

private static class Utensil {

static final String NAME = "pan";

}

private static class Dessert {

static final String NAME = "cake";

}

}

这个教训很清楚:永远不要将多个顶级类或接口放在一个源文件中。 遵循这个规则保证在编译时不能有多个定义。 这又保证了编译生成的类文件以及生成的程序的行为与源文件传递给编译器的顺序无关。

自Java 5以来,泛型已经成为该语言的一部分。 在泛型之前,你必须转换从集合中读取的每个对象。 如果有人不小心插入了错误类型的对象,则在运行时可能会失败。 使用泛型,你告诉编译器在每个集合中允许哪些类型的对象。 编译器会自动插入强制转换,并在编译时告诉你是否尝试插入错误类型的对象。 这样做的结果是既安全又清晰的程序,但这些益处,不限于集合,是有代价的。 本章告诉你如何最大限度地提高益处,并将并发症降至最低。