Effective Java 3rd 中文版(item:16-20)

16. 在公共类中使用访问方法而不是公共属性

有时候,你可能会试图写一些退化的类(degenerate classes),除了集中实例属性之外别无用处:

1
2
3
4
5
// Degenerate classes like this should not be public!
class Point {
public double x;
public double y;
}

由于这些类的数据属性可以直接被访问,因此这些类不提供封装的好处(条目 15)。 如果不更改API,则无法更改其表示形式,无法强制执行不变量,并且在访问属性时无法执行辅助操作。 坚持面向对象的程序员觉得这样的类是厌恶的,应该被具有私有属性和公共访问方法的类(getter)所取代,而对于可变类来说,它们应该被替换为setter设值方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Encapsulation of data by accessor methods and mutators
class Point {
private double x;
private double y;

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

public double getX() { return x; }

public double getY() { return y; }

public void setX(double x) { this.x = x; }

public void setY(double y) { this.y = y; }

}

当然,对于公共类来说,坚持面向对象是正确的:如果一个类在其包之外是可访问的,则提供访问方法来保留更改类内部表示的灵活性。如果一个公共类暴露其数据属性,那么以后更改其表示形式基本上没有可能,因为客户端代码可以散布在很多地方。

但是,如果一个类是包级私有的,或者是一个私有的内部类,那么暴露它的数据属性就没有什么本质上的错误——假设它们提供足够描述该类提供的抽象。在类定义和使用它的客户端代码中,这种方法比访问方法产生更少的视觉混乱。 虽然客户端代码绑定到类的内部表示,但是这些代码仅限于包含该类的包。 如果类的内部表示是可取的,可以在不触碰包外的任何代码的情况下进行更改。 在私有内部类的情况下,更改作用范围进一步限制在封闭类中。

Java平台类库中的几个类违反了公共类不应直接暴露属性的建议。 着名的例子包括java.awt包中的PointDimension类。 这些类别应该被视为警示性的示例,而不是模仿的例子。 如条目 67所述,暴露Dimension的内部结构的决定是一个严重的性能问题,这个问题在今天仍然存在。

虽然公共类直接暴露属性并不是一个好主意,但是如果属性是不可变的,那么危害就不那么大了。当一个属性是只读的时候,除了更改类的API外,你不能改变类的内部表示形式,也不能采取一些辅助的行为,但是可以加强不变性。例如,下面的例子中保证每个实例表示一个有效的时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Public class with exposed immutable fields - questionable

public final class Time {
private static final int HOURS_PER_DAY = 24;
private static final int MINUTES_PER_HOUR = 60;
public final int hour;
public final int minute;

public Time(int hour, int minute) {
if (hour < 0 || hour >= HOURS_PER_DAY)
throw new IllegalArgumentException("Hour: " + hour);
if (minute < 0 || minute >= MINUTES_PER_HOUR)
throw new IllegalArgumentException("Min: " + minute);
this.hour = hour;
this.minute = minute;
}

... // Remainder omitted
}

总之,公共类不应该暴露可变属性。 公共累暴露不可变属性的危害虽然仍然存在问题,但其危害较小。 然而,有时需要包级私有或私有内部类来暴露属性,无论此类是否是可变的。

17. 最小化可变性

不可变类简单来说是它的实例不能被修改的类。 包含在每个实例中的所有信息在对象的生命周期中是固定的,因此不会观察到任何变化。 Java平台类库包含许多不可变的类,包括String类,基本类型包装类以及BigInteger类和BigDecimal类。 有很多很好的理由:不可变类比可变类更容易设计,实现和使用。 他们不太容易出错,更安全。

要使一个类不可变,请遵循以下五条规则:

  1. 不要提供修改对象状态的方法(也称为mutators)。
  2. 确保这个类不能被继承。 这可以防止粗心的或恶意的子类,假设对象的状态已经改变,从而破坏类的不可变行为。 防止子类化通常是通过final修饰类,但是我们稍后将讨论另一种方法。
  3. 把所有属性设置为final。通过系统强制执行,清楚地表达了你的意图。 另外,如果一个新创建的实例的引用从一个线程传递到另一个线程而没有同步,就必须保证正确的行为,正如内存模型[JLS,17.5; Goetz06,16]所述。
  4. 把所有的属性设置为private。 这可以防止客户端获得对属性引用的可变对象的访问权限并直接修改这些对象。 虽然技术上允许不可变类具有包含基本类型数值的公共final属性或对不可变对象的引用,但不建议这样做,因为它不允许在以后的版本中更改内部表示(项目15和16)。
  5. 确保对任何可变组件的互斥访问。 如果你的类有任何引用可变对象的属性,请确保该类的客户端无法获得对这些对象的引用。 切勿将这样的属性初始化为客户端提供的对象引用,或从访问方法返回属性。 在构造方法,访问方法和readObject方法(条目 88)中进行防御性拷贝(条目 50)。

以前条目中的许多示例类都是不可变的。 其中这样的类是条目 11中的PhoneNumber类,它具有每个属性的访问方法(accessors),但没有相应的设值方法(mutators)。 这是一个稍微复杂一点的例子:

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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// Immutable complex number class

public final class Complex {

private final double re;

private final double im;

public Complex(double re, double im) {

this.re = re;

this.im = im;

}

public double realPart() {

return re;

}

public double imaginaryPart() {

return im;

}

public Complex plus(Complex c) {

return new Complex(re + c.re, im + c.im);

}

public Complex minus(Complex c) {

return new Complex(re - c.re, im - c.im);

}

public Complex times(Complex c) {

return new Complex(re * c.re - im * c.im,

re * c.im + im * c.re);

}

public Complex dividedBy(Complex c) {

double tmp = c.re * c.re + c.im * c.im;

return new Complex((re * c.re + im * c.im) / tmp,

(im * c.re - re * c.im) / tmp);

}

@Override

public boolean equals(Object o) {

if (o == this) {

return true;

}

if (!(o instanceof Complex)) {

return false;

}

Complex c = (Complex) o;

// See page 47 to find out why we use compare instead of ==

return Double.compare(c.re, re) == 0

&& Double.compare(c.im, im) == 0;

}

@Override

public int hashCode() {

return 31 * Double.hashCode(re) + Double.hashCode(im);

}

@Override

public String toString() {

return "(" + re + " + " + im + "i)";

}
}

这个类代表了一个复数(包含实部和虚部的数字)。 除了标准的Object方法之外,它还为实部和虚部提供访问方法,并提供四个基本的算术运算:加法,减法,乘法和除法。 注意算术运算如何创建并返回一个新的Complex实例,而不是修改这个实例。 这种模式被称为函数式方法,因为方法返回将操作数应用于函数的结果,而不修改它们。 与其对应的过程(procedural)或命令(imperative)的方法相对比,在这种方法中,将一个过程作用在操作数上,导致其状态改变。 请注意,方法名称是介词(如plus)而不是动词(如add)。 这强调了方法不会改变对象的值的事实。 BigIntegerBigDecimal类没有遵守这个命名约定,并导致许多使用错误。

如果你不熟悉函数式方法,可能会显得不自然,但它具有不变性,具有许多优点。 不可变对象很简单。 一个不可变的对象可以完全处于一种状态,也就是被创建时的状态。 如果确保所有的构造方法都建立了类不变量,那么就保证这些不变量在任何时候都保持不变,使用此类的程序员无需再做额外的工作。 另一方面,可变对象可以具有任意复杂的状态空间。 如果文档没有提供由设置(mutator)方法执行的状态转换的精确描述,那么可靠地使用可变类可能是困难的或不可能的。

不可变对象本质上是线程安全的; 它们不需要同步。 被多个线程同时访问它们时并不会被破坏。 这是实现线程安全的最简单方法。 由于没有线程可以观察到另一个线程对不可变对象的影响,所以不可变对象可以被自由地共享。 因此,不可变类应鼓励客户端尽可能重用现有的实例。 一个简单的方法是为常用的值提供公共的静态 final常量。 例如,Complex类可能提供这些常量:

1
2
3
public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE = new Complex(1, 0);
public static final Complex I = new Complex(0, 1);

这种方法可以更进一步。 一个不可变的类可以提供静态的工厂(条目 1)来缓存经常被请求的实例,以避免在现有的实例中创建新的实例。 所有基本类型的包装类和BigInteger类都是这样做的。 使用这样的静态工厂会使客户端共享实例而不是创建新实例,从而减少内存占用和垃圾回收成本。 在设计新类时,选择静态工厂代替公共构造方法,可以在以后增加缓存的灵活性,而不需要修改客户端。

不可变对象可以自由分享的结果是,你永远不需要做出防御性拷贝( defensive copies)(条目 50)。 事实上,永远不需要做任何拷贝,因为这些拷贝永远等于原始对象。 因此,你不需要也不应该在一个不可变的类上提供一个clone方法或拷贝构造方法(copy constructor)(条目 13)。 这一点在Java平台的早期阶段还不是很好理解,所以String类有一个拷贝构造方法,但是它应该尽量很少使用(条目 6)。

不仅可以共享不可变的对象,而且可以共享内部信息。 例如,BigInteger类在内部使用符号数值表示法。 符号用int值表示,数值用int数组表示。 negate方法生成了一个数值相同但符号相反的新BigInteger实例。 即使它是可变的,也不需要复制数组;新创建的BigInteger指向与原始相同的内部数组。

不可变对象为其他对象提供了很好的构件(building blocks),无论是可变的还是不可变的。 如果知道一个复杂组件的内部对象不会发生改变,那么维护复杂对象的不变量就容易多了。这一原则的特例是,不可变对象可以构成Map对象的键和Set的元素,一旦不可变对象作为Map的键或Set里的元素,即使破坏了MapSet的不可变性,但不用担心它们的值会发生变化。

不可变对象提供了免费的原子失败机制(条目 76)。它们的状态永远不会改变,所以不可能出现临时的不一致。

不可变类的主要缺点是对于每个不同的值都需要一个单独的对象。 创建这些对象可能代价很高,特别是如果是大型的对象下。 例如,假设你有一个百万位的BigInteger ,你想改变它的低位:

1
2
3
BigInteger moby = ...;

moby = moby.flipBit(0);

flipBit方法创建一个新的BigInteger实例,也是一百万位长,与原始位置只有一位不同。 该操作需要与BigInteger大小成比例的时间和空间。 将其与java.util.BitSet对比。 像BigInteger一样,BitSet表示一个任意长度的位序列,但与BigInteger不同,BitSet是可变的。 BitSet类提供了一种方法,允许你在固定时间内更改百万位实例中单个位的状态:

1
2
3
BitSet moby = ...;

moby.flip(0);

如果执行一个多步操作,在每一步生成一个新对象,除最终结果之外丢弃所有对象,则性能问题会被放大。这里有两种方式来处理这个问题。第一种办法,先猜测一下会经常用到哪些多步的操作,然后讲它们作为基本类型提供。如果一个多步操作是作为一个基本类型提供的,那么不可变类就不必在每一步创建一个独立的对象。在内部,不可变的类可以是任意灵活的。 例如,BigInteger有一个包级私有的可变的“伙伴类(companion class)”,它用来加速多步操作,比如模幂运算( modular exponentiation)。出于前面所述的所有原因,使用可变伙伴类比使用BigInteger要困难得多。 幸运的是,你不必使用它:BigInteger类的实现者为你做了很多努力。

如果你可以准确预测客户端要在你的不可变类上执行哪些复杂的操作,那么包级私有可变伙伴类的方式可以正常工作。如果不是的话,那么最好的办法就是提供一个公开的可变伙伴类。 这种方法在Java平台类库中的主要例子是String类,它的可变伙伴类是StringBuilder(及其过时的前身StringBuffer类)。

现在你已经知道如何创建一个不可改变类,并且了解不变性的优点和缺点,下面我们来讨论几个设计方案。 回想一下,为了保证不变性,一个类不得允许子类化。 这可以通过使类用 final 修饰,但是还有另外一个更灵活的选择。 而不是使不可变类设置为 final,可以使其所有的构造方法私有或包级私有,并添加公共静态工厂,而不是公共构造方法(条目 1)。 为了具体说明这种方法,下面以Complex为例,看看如何使用这种方法:

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
// Immutable class with static factories instead of constructors

public class Complex {

private final double re;

private final double im;

private Complex(double re, double im) {

[this.re](http://this.re) = re;

[this.im](http://this.im) = im;

}

public static Complex valueOf(double re, double im) {

return new Complex(re, im);

}

... // Remainder unchanged

}

这种方法往往是最好的选择。 这是最灵活的,因为它允许使用多个包级私有实现类。 对于驻留在包之外的客户端,不可变类实际上是final的,因为不可能继承来自另一个包的类,并且缺少公共或受保护的构造方法。 除了允许多个实现类的灵活性以外,这种方法还可以通过改进静态工厂的对象缓存功能来调整后续版本中类的性能。

BigIntegerBigDecimal被写入时,不可变类必须是有效的final,因此它们的所有方法都可能被重写。不幸的是,在保持向后兼容性的同时,这一事实无法纠正。如果你编写一个安全性取决于来自不受信任的客户端的BigInteger或BigDecimal参数的不变类时,则必须检查该参数是“真实的”BigInteger还是BigDecimal,而不应该是不受信任的子类的实例。如果是后者,则必须在假设可能是可变的情况下保护性拷贝(defensively copy)(条目 50):

1
2
3
4
5
public static BigInteger safeInstance(BigInteger val) {

return val.getClass() == BigInteger.class ?
val : new BigInteger(val.toByteArray());
}

在本条目开头关于不可变类的规则说明,没有方法可以修改对象,并且它的所有属性必须是final的。事实上,这些规则比实际需要的要强硬一些,其实可以有所放松来提高性能。 事实上,任何方法都不能在对象的状态中产生外部可见的变化。 然而,一些不可变类具有一个或多个非final属性,在第一次需要时将开销昂贵的计算结果缓存在这些属性中。 如果再次请求相同的值,则返回缓存的值,从而节省了重新计算的成本。 这个技巧的作用恰恰是因为对象是不可变的,这保证了如果重复的话,计算会得到相同的结果。

例如,PhoneNumber类的hashCode方法(第53页的条目 11)在第一次调用改方法时计算哈希码,并在再次调用时对其进行缓存。 这种延迟初始化(条目 83)的一个例子,String类也使用到了。

关于序列化应该加上一个警告。 如果你选择使您的不可变类实现Serializable接口,并且它包含一个或多个引用可变对象的属性,则必须提供显式的readObjectreadResolve方法,或者使用ObjectOutputStream.writeUnsharedObjectInputStream.readUnshared方法,即默认的序列化形式也是可以接受的。 否则攻击者可能会创建一个可变的类的实例。 这个主题会在条目 88中会详细介绍。

总而言之,坚决不要为每个属性编写一个get方法后再编写一个对应的set方法。 除非有充分的理由使类成为可变类,否则类应该是不可变的。 不可变类提供了许多优点,唯一的缺点是在某些情况下可能会出现性能问题。 你应该始终使用较小的值对象(如PhoneNumberComplex),使其不可变。 (Java平台类库中有几个类,如java.util.Datejava.awt.Point,本应该是不可变的,但实际上并不是)。你应该认真考虑创建更大的值对象,例如StringBigInteger ,设成不可改变的。 只有当你确认有必要实现令人满意的性能(条目 67)时,才应该为不可改变类提供一个公开的可变伙伴类。

对于一些类来说,不变性是不切实际的。如果一个类不能设计为不可变类,那么也要尽可能地限制它的可变性。减少对象可以存在的状态数量,可以更容易地分析对象,以及降低出错的可能性。因此,除非有足够的理由把属性设置为非 final 的情况下,否则应该每个属性都设置为 final 的。把本条目的建议与条目15的建议结合起来,你自然的倾向就是:除非有充分的理由不这样做,否则应该把每个属性声明为私有final的

构造方法应该创建完全初始化的对象,并建立所有的不变性。 除非有令人信服的理由,否则不要提供独立于构造方法或静态工厂的公共初始化方法。 同样,不要提供一个“reinitialize”方法,使对象可以被重用,就好像它是用不同的初始状态构建的。 这样的方法通常以增加的复杂度为代价,仅仅提供很少的性能优势。

CountDownLatch类是这些原理的例证。 它是可变的,但它的状态空间有意保持最小范围内。 创建一个实例,使用它一次,并完成:一旦countdown锁的计数器已经达到零,不能再重用它。

在这个条目中,应该添加关于Complex类的最后一个注释。 这个例子只是为了说明不变性。 这不是一个工业强度复杂的复数实现。 它对复数使用了乘法和除法的标准公式,这些公式不正确会进行不正确的四舍五入,没有为复数的NaN和无穷大提供良好的语义[Kahan91,Smith62,Thomas94]。

18. 组合优于继承

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

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

为了具体说明,假设有一个使用HashSet的程序。 为了调整程序的性能,需要查询HashSe,从创建它之后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时数量也会下降)。 为了提供这个功能,编写了一个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。

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

19. 如果使用继承则设计,并文档说明,否则不该使用

条目 18中提醒你注意继承没有设计和文档说明的“外来”类的子类化的危险。 那么为了继承而设计和文档说明一个类是什么意思呢?

首先,这个类必须准确地描述重写这个方法带来的影响。 换句话说,该类必须文档说明可重写方法的自用性(self-use)。 对于每个公共或受保护的方法,文档必须指明方法调用哪些重写方法,以何种顺序以及每次调用的结果如何影响后续处理。 (重写方法,这里是指非final修饰的方法,无论是公开还是保护的。)更一般地说,一个类必须文档说明任何可能调用可重写方法的情况。 例如,后台线程或者静态初始化代码块可能会调用这样的方法。

调用可重写方法的方法在文档注释结束时包含对这些调用的描述。 这些描述在规范中特定部分,标记为“Implementation Requirements,”,由Javadoc标签@implSpec生成。 本节介绍该方法的内部工作原理。 下面是从java.util.AbstractCollection类的规范中拷贝的例子:

1
2
3
4
public boolean remove(Object o)
Removes a single instance of the specified element from this collection, if it is present (optional operation). More formally, removes an element e such that Objects.equals(o, e), if this collection contains one or more such elements. Returns true if this collection contained the specified element (or equivalently, if this collection changed as a result of the call).

Implementation Requirements: This implementation iterates over the collection looking for the specified element. If it finds the element, it removes the element from the collection using the iterator’s remove method. Note that this implementation throws an UnsupportedOperationException if the iterator returned by this collection’s iterator method does not implement the remove method and this collection contains the specified object.

从该集合中删除指定元素的单个实例(如果存在,optional实例操作)。 更正式地说,如果这个集合包含一个或多个这样的元素,删除使得Objects.equals(o, e)的一个元素e。 如果此集合包含指定的元素(或者等同于此集合因调用而发生了更改),则返回true。

实现要求:这个实现迭代遍历集合查找指定元素。 如果找到元素,则使用迭代器的remove方法从集合中删除元素。 请注意,如果此集合的iterator方法返回的迭代器未实现remove方法,并且此集合包含指定的对象,则此实现将引发UnsupportedOperationException异常。

这个文档毫无疑问地说明,重写iterator方法会影响remove方法的行为。 它还描述了iterator方法返回的Iterator行为将如何影响remove方法的行为。 与条目 18中的情况相反,在这种情况下,程序员继承HashSet并不能说明重写add方法是否会影响addAll方法的行为。

但是,这是否违背了一个良好的API文档应该描述给定的方法是什么,而不是它是如何做的呢? 是的,它确实!这是继承违反封装这一事实的不幸后果。要文档说明一个类以便可以安全地进行子类化,必须描述清楚那些没有详细说明的实现细节。

@implSpec标签是在Java 8中添加的,并且在Java 9中被大量使用。这个标签应该默认启用,但是从Java 9开始,除非通过命令行开关-tag "apiNote:a:API Note:”,否则Javadoc实用工具仍然会忽略它。

设计继承涉及的不仅仅是文档说明自用的模式。 为了让程序员能够写出有效的子类而不会带来不适当的痛苦,一个类可能以明智选择的受保护方法的形式提供内部工作,或者在罕见的情况下,提供受保护的属性。 例如,考虑java.util.AbstractList中的removeRange方法:

1
2
3
4
5
6
7
8
protected void removeRange(int fromIndex, int toIndex)
Removes from this list all of the elements whose index is between fromIndex, inclusive, and toIndex, exclusive. Shifts any succeeding elements to the left (reduces their index). This call shortens the list by (toIndex - fromIndex) elements. (If toIndex == fromIndex, this operation has no effect.)
This method is called by the clear operation on this list and its sublists. Overriding this method to take advantage of the internals of the list implementation can substantially improve the performance of the clear operation on this list and its sublists.
Implementation Requirements: This implementation gets a list iterator positioned before fromIndex and repeatedly calls ListIterator.nextfollowed by ListIterator.remove, until the entire range has been removed. Note: If ListIterator.remove requires linear time, this implementation requires quadratic time.
Parameters:
fromIndex index of first element to be removed.

toIndex index after last element to be removed.

从此列表中删除索引介于fromIndex(包含)和inclusive(不含)之间的所有元素。 将任何后续元素向左移(减少索引)。 这个调用通过(toIndex - fromIndex)元素来缩短列表。 (如果toIndex == fromIndex,则此操作无效。)

这个方法是通过列表及其子类的clear操作来调用的。重写这个方法利用列表内部实现的优势,可以大大提高列表和子类的clear操作性能。

实现要求:这个实现获取一个列表迭代器,它位于fromIndex之前,并重复调用ListIterator.removeListIterator.next方法,直到整个范围被删除。 注意:如果ListIterator.remove需要线性时间,则此实现需要平方级时间。

参数:
fromIndex 要移除的第一个元素的索引
toIndex 要移除的最后一个元素之后的索引

这个方法对List实现的最终用户来说是没有意义的。 它仅仅是为了使子类很容易提供一个快速clear方法。 在没有removeRange方法的情况下,当在子列表上调用clear方法,子类将不得不使用平方级的时间,否则,或从头重写整个subList机制——这不是一件容易的事情!

那么当你设计一个继承类的时候,你如何决定暴露哪些的受保护的成员呢? 不幸的是,没有灵丹妙药。 所能做的最好的就是努力思考,做出最好的测试,然后通过编写子类来进行测试。 应该尽可能少地暴露受保护的成员,因为每个成员都表示对实现细节的承诺。 另一方面,你不能暴露太少,因为失去了保护的成员会导致一个类几乎不能用于继承。

测试为继承而设计的类的唯一方法是编写子类。 如果你忽略了一个关键的受保护的成员,试图编写一个子类将会使得遗漏痛苦地变得明显。 相反,如果编写的几个子类,而且没有一个使用受保护的成员,那么应该将其设为私有。 经验表明,三个子类通常足以测试一个可继承的类。 这些子类应该由父类作者以外的人编写。

当你为继承设计一个可能被广泛使用的类的时候,要意识到你永远承诺你文档说明的自用模式以及隐含在其保护的方法和属性中的实现决定。 这些承诺可能会使后续版本中改善类的性能或功能变得困难或不可能。 因此,在发布它之前,你必须通过编写子类来测试你的类

另外,请注意,继承所需的特殊文档混乱了正常的文档,这是为创建类的实例并在其上调用方法的程序员设计的。 在撰写本文时,几乎没有工具将普通的API文档从和仅仅针对子类实现的信息,分离出来。

还有一些类必须遵守允许继承的限制。 构造方法绝不能直接或间接调用可重写的方法。 如果违反这个规则,将导致程序失败。 父类构造方法在子类构造方法之前运行,所以在子类构造方法运行之前,子类中的重写方法被调用。 如果重写方法依赖于子类构造方法执行的任何初始化,则此方法将不会按预期运行。 为了具体说明,这是一个违反这个规则的类:

1
2
3
4
5
6
7
8
public class Super {
// Broken - constructor invokes an overridable method
public Super() {
overrideMe();
}
public void overrideMe() {
}
}

以下是一个重写overrideMe方法的子类,Super类的唯一构造方法会错误地调用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public final class Sub extends Super {
// Blank final, set by constructor
private final Instant instant;

Sub() {
instant = Instant.now();
}

// Overriding method invoked by superclass constructor
@Override public void overrideMe() {
System.out.println(instant);
}

public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
}

你可能期望这个程序打印两次instant实例,但是它第一次打印出null,因为在Sub构造方法有机会初始化instant属性之前,overrideMe被Super构造方法调用。 请注意,这个程序观察两个不同状态的final属性! 还要注意的是,如果overrideMe方法调用了instant实例中任何方法,那么当父类构造方法调用overrideMe时,它将抛出一个NullPointerException异常。 这个程序不会抛出NullPointerException的唯一原因是println方法容忍null参数。

请注意,从构造方法中调用私有方法,其中任何一个方法都不可重写的,那么final方法和静态方法是安全的。

CloneableSerializable接口在设计继承时会带来特殊的困难。 对于为继承而设计的类来说,实现这些接口通常不是一个好主意,因为这会给继承类的程序员带来很大的负担。 然而,可以采取特殊的行动来允许子类实现这些接口,而不需要强制这样做。 这些操作在条目 13和条目 86中有描述。

如果你决定在为继承而设计的类中实现CloneableSerializable接口,那么应该知道,由于clonereadObject方法与构造方法相似,所以也有类似的限制:clone和readObject都不会直接或间接调用可重写的方法。在readObject的情况下,重写方法将在子类的状态被反序列化之前运行。 在clone的情况下,重写方法将在子类的clone方法有机会修复克隆的状态之前运行。 在任何一种情况下,都可能会出现程序故障。 在clone的情况下,故障可能会损坏原始对象以及被克隆对象本身。 例如,如果重写方法假定它正在修改对象的深层结构的拷贝,但是尚未创建拷贝,则可能发生这种情况。

最后,如果你决定在为继承设计的类中实现Serializable接口,并且该类有一个readResolvewriteReplace方法,则必须使readResolvewriteReplace方法设置为受保护而不是私有。 如果这些方法是私有的,它们将被子类无声地忽略。 这是另一种情况,把实现细节成为类的API的一部分,以允许继承。

到目前为止,设计一个继承类需要很大的努力,并且对这个类有很大的限制。 这不是一个轻率的决定。 有些情况显然是正确的,比如抽象类,包括接口的骨架实现(skeletal implementations)(条目 20)。 还有其他的情况显然是错误的,比如不可变的类(条目 17)。

但是普通的具体类呢? 传统上,它们既不是final的,也不是为了子类化而设计和文档说明的,但是这种情况是危险的。每次修改这样的类,则继承此类的子类将被破坏。 这不仅仅是一个理论问题。 在修改非final的具体类的内部之后,接收与子类相关的错误报告并不少见,这些类没有为继承而设计和文档说明。

解决这个问题的最好办法是,在没有想要安全地子类化的设计和文档说明的类中禁止子类化。 有两种方法禁止子类化。 两者中较容易的是声明类为final。 另一种方法是使所有的构造方法都是私有的或包级私有的,并且添加公共静态工厂来代替构造方法。 这个方案在内部提供了使用子类的灵活性,在条目 17中讨论过。两种方法都是可以接受的。

这个建议可能有些争议,因为许多程序员已经习惯于继承普通的具体类来增加功能,例如通知和同步等功能,或限制原有类的功能。 如果一个类实现了捕获其本质的一些接口,比如Set,List或Map,那么不应该为了禁止子类化而感到愧疚。 在条目 18中描述的包装类模式为增强功能提供了继承的优越选择。

如果一个具体的类没有实现一个标准的接口,那么你可能会通过禁止继承来给一些程序员带来不便。 如果你觉得你必须允许从这样的类继承,一个合理的方法是确保类从不调用任何可重写的方法,并文档说明这个事实。 换句话说,完全消除类的自用(self-use)的可重写的方法。 这样做,你将创建一个合理安全的子类。 重写一个方法不会影响任何其他方法的行为。

你可以机械地消除类的自我使用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移动到一个私有的“帮助器方法”,并让每个可重写的方法调用其私有的帮助器方法。 然后用直接调用可重写方法的专用帮助器方法来替换每个自用的可重写方法。

你可以机械地消除类的自用的重写方法,而不会改变其行为。 将每个可重写的方法的主体移到一个私有的“辅助方法(helper method)”,并让每个可重写的方法调用其私有的辅助方法。 然后用直接调用可重写方法的专用辅助方法来替换每个自用的可重写方法。

总之,设计一个继承类是一件很辛苦的事情。 你必须文档说明所有的自用模式,一旦你文档说明了它们,必须承诺为他们的整个生命周期。 如果你不这样做,子类可能会依赖于父类的实现细节,并且如果父类的实现发生改变,子类可能会损坏。 为了允许其他人编写高效的子类,可能还需要导出一个或多个受保护的方法。 除非你知道有一个真正的子类需要,否则你可能最好是通过声明你的类为final禁止继承,或者确保没有可访问的构造方法。

20. 接口优于抽象类

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

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

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

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

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

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

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

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

void actSensitive();
}

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Concrete implementation built atop skeletal implementation

static List<Integer> intArrayAsList(int[] a) {

Objects.requireNonNull(a);

// The diamond operator is only legal here in Java 9 and later

// If you're using an earlier release, specify <Integer>

return new AbstractList<>() {

@Override public Integer get(int i) {

return a[i]; // Autoboxing ([Item 6](https://www.safaribooksonline.com/library/view/effective-java-third/9780134686097/ch2.xhtml#lev6))

}

@Override public Integer set(int i, Integer val) {

int oldVal = a[I];

a[i] = val; // Auto-unboxing

return oldVal; // Autoboxing

}

@Override public int size() {

return a.length;

}

};

}

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Skeletal implementation class

public abstract class AbstractMapEntry<K,V>

implements Map.Entry<K,V> {

// Entries in a modifiable map must override this method

@Override public V setValue(V value) {

throw new UnsupportedOperationException();

}

// Implements the general contract of Map.Entry.equals

@Override public boolean equals(Object o) {

if (o == this)

return true;

if (!(o instanceof Map.Entry))

return false;

Map.Entry<?,?> e = (Map.Entry) o;

return Objects.equals(e.getKey(), getKey())

&& Objects.equals(e.getValue(), getValue());

}

// Implements the general contract of Map.Entry.hashCode

@Override public int hashCode() {

return Objects.hashCode(getKey())

^ Objects.hashCode(getValue());

}

@Override public String toString() {

return getKey() + "=" + getValue();

}

}

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

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

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

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