继承是实现代码复用的一个有效方法,但并不总是实现代码复用的最好方法。继承使用不当,会导致系统非常脆弱。在同一个包的作用域下使用继承是安全的,因为子类和父类的实现都在同一个程序员的控制之下;或者,继承一个设计规范并且有扩展文档的类也是安全的。然而,跨包边界继承普通类是危险的。提醒一下,本书中使用的继承(inheritance)特指实现继承(implementation inheritance),一个类继承另一个类的情况。本条中讨论的问题不适用于接口继承(interface inheritance),类实现接口或接口继承另一个接口。
与调用方法不同,继承会破坏封装。换句话说,子类依赖于父类的实现细节。父类的实现可能会随着版本的迭代而发生变化,当父类代码改变时,即使子类的代码没有发生变化,子类也可能被破坏而失去其本身的功能。因此,子类必须随着父类的变化而调整,除非父类的作者提前为相应的扩展做了设计并记录在文档里。
举个例子,假设我们有一个程序用到了 HashSet。为了调整程序性能,我们需要查询 HashSet 自创建以来添加了多少元素(不要与当前大小混淆,当前大小会随着元素的删除而减少)。为了提供这个功能,我们编写了一个 HashSet 变体来记录元素插入次数,并提供了一个接口供外部访问该累加量。HastSet 类包含两个添加元素的方法:add 和 addAll,因此我们重写了这两个方法:
// 错误 - 继承使用不当!
public class InstrumentedHashSet<E> extends HashSet<E> {
// 元素添加次数
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 方法添加了 3 个元素。顺便提一次,我们使用 List.of
创建了一个列表,该方法是在 Java 9 中添加的;如果使用的是早期版本,请改用 Arrays.asList
:
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.add(List.of("Snap", "Crackle", "Pop"));
我们期待的效果是 getAddCount
方法返回 3,但它实际上会返回 6。哪里出错了呢?实际上,HashSet 内部 addAll 方法是在其 add 方法之上实现的,这种实现方法相当合理,尽管 HashSet 文档上没有记录这个实现细节。InstrumentedHashSet 的 addAll 方法执行了 addCount 值加 3,然后通过 super.addAll
调用了父类 HashSet 的 addAll 方法。这反过来又为每个元素调用一次在 InstrumentedHashSet
中被覆盖的 add
方法。这三次调用中的每一次都执行了 addCount 值加一,addCount 总共加 6:使用 addAll 方法添加的每个元素都加了两遍 addCount 的值。
我们可以通过去除InstrumentedHashSet
子类中重写的 addAll 方法来解决这个问题。虽然结果正确,但它的正确性依然取决于 HashSet 中的 addAll 方法是在其 add 方法之上实现的这一事实。这种”自用(self-use)“是实现细节,不能保证在 Java 平台的所有实现中都适用,并且会随着版本的变化而变化。因此,将会导致InstrumentedHashSet
类的脆弱性。
重写 addAll 方法来迭代指定的集合会稍微好一些,为每个元素调用一次 add 方法。这将保证正确的结果,无论 HashSet 的 addAll 方法是否在其 add 方法之上实现,因为 HashSet 的 addAll 实现将不再被调用。然而,这种方法并不能解决所有的问题。它相当于重新实现父类方法,无论其会不会导致自用,这是困难的、耗时的、容易出错的,并且可能会降低性能。此外,由于有些方法必须访问子类的私有字段,重写父类方法并不总是可行的。
子类脆弱性的一个相关原因是它们的父类可以在后续版本中获取新方法。假设一个程序的安全性取决于插入到某个集合中的所有元素都满足某个谓词这一事实。这可以通过对集合进行子类化并覆盖能够添加元素的每个方法来保证,以确保在添加元素之前满足谓词。这可以正常工作,直到在后续版本中将能够插入元素的新方法添加到父类中。一旦发生这种情况,就可以仅通过调用新方法来添加“非法”元素,该方法在子类中没有被覆盖。这不是一个纯粹的理论问题。当改造 Hashtable 和 Vector 加入到集合框架时,必须修复几个这种性质的安全漏洞。
这些问题都是由重写方法导致的。如果只是添加新方法并避免覆盖现有方法,您可能会认为扩展类是安全的。虽然这种扩展更安全,但也并非没有风险。如果弗雷在后续版本中添加了一个新方法,并且这个新方法的名称和子类中已存在的方法名一致,但返回值不一致,那么将会不再编译子类[JLS, 8.4, 8.3]。如果子类方法与父类方法具有相同方法名和返回类型,那么子类方法会覆盖父类方法,因此,又会遇到上述问题。此外,新的父类方法能否像你期望的那样实现也不确定,因为在编写子类方法时,并没有考虑到同名父类方法的实现。
幸运的是,有一种方法可以避免上述提到的所有问题。与其扩展现有类,不如为新类提供一个引用现有类实例的私有字段。这种方法称之为组合(composition),因为现有类成为了新类的一部分。新类中的每个实例方法调用现有类实例中的相应方法并返回结果。这种结果称之为转发(forwarding),并且新类中的方法称之为转发方法(forwarding method)。这样生成的新类将坚如磐石,不依赖于现有类的实现细节。即使像现有类添加新方法也不会对新类产生影响。为了具体说明,下面是适用组合和转发方式实现的 InstrumentedHashSet 。这种实现分为两部分,一部分是类本身,另一部分是一个可重用的、仅包含所有转发方法的转发类:
// 封装类 - 使用组合替代继承
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;
}
}
// 可重用的转发类
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(); }
}
InstrumentedSet 类的设计是基于已存在的 Set 接口来实现的,该接口捕获了 HashSet 类的功能。除了坚固之外,这种设计还非常灵活。 InstrumentedSet 类实现了 Set 接口,并有一个构造函数,其参数也是 Set 类型。本质上,该类将一个 Set 转换为另一个,添加了检测功能。与基于继承的方法不同,后者仅适用于单个具体类并且需要为超类中每个受支持的构造函数提供单独的构造函数,包装类可用于检测任何 Set 实现,并将与任何预先存在的构造函数一起工作:
Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp));
Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedSet 类甚至可以用来临时检测一个已经在没有检测的情况下使用的集合实例:
static void walk(Set<Dog> dogs) {
InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
... // 在该方法里使用 iDogs 而不是 dogs
}
InstrumentedSet 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。这也称为装饰器模式 [Gamma95],因为 InstrumentedSet 类通过添加检测方法来“装饰”一个集合。有时组合和转发的组合被松散地称为委托。从技术上讲,它不是委托,除非包装对象将自己传递给被包装对象 [Lieberman86; Gamma95]。
包装类的缺点很少。一个警告是包装类不适合在回调(callbacks)框架中使用,其中对象将自引用传递给其他对象以进行后续调用(“回调”)。因为一个被包装的对象不知道它的包装器,它传递一个对自身的引用(this)并且回调避开了包装器。这被称为 SELF 问题 [Lieberman86]。有些人担心转发方法调用的性能影响或包装对象的内存占用影响。事实证明,两者在实践中都没有太大影响。编写转发方法很繁琐,但每个接口只需编写一次可重用的转发类,而且提供了一个转发类。例如,Guava 为所有集合接口 [Guava] 提供转发类。
继承仅适用于子类确实是父类的子类型的情况。换句话说,只有当两个类之间存在“is-a”关系时,类 B 才应该扩展类 A。如果你想让 B 类扩展 A 类,问自己一个问题:每个 B 真的都是 A 吗?如果您不能对这个问题如实回答是,B 不应该扩展 A。如果答案是否定的,通常情况下 B 应该包含 A 的私有实例并公开不同的 API:A 不是 B 的必要部分,只是其实现的一个细节。
Java 平台库中有许多明显违反此原则的地方。例如,堆栈不是 vector,因此 Stack 不应继承 Vector。同样,属性列表不是哈希表,因此 Properties 不应继承 Hashtable。在这两种情况下,组合可能更好。
如果在适合组合的情况下使用继承,则没必要暴露实现细节。生成的 API 将与原始实现联系在一起,永远限制类的性能。更严重的是,通过公开内部结构,可以让客户直接访问它们。至少,它会导致语义混乱。例如,如果 p 引用一个 Properties 实例,那么 p.getProperty(key) 可能会产生与 p.get(key) 不同的结果:前一种方法会考虑默认值,而后一种方法是从 Hashtable 继承的,不会考虑默认值。最严重的是,客户端可以通过直接修改父类来破坏子类的不变量。在 Properties 中,设计者打算只允许字符串作为键和值,但是直接访问底层的 Hashtable 允许违反这种不变量。一旦违反,就不能再使用 Properties API 的其他部分(load 和 Store)。到发现这个问题时,再纠正为时已晚,因为客户端依赖于使用非字符串键和值。
在决定使用继承代替组合之前,你应该问自己最后一组问题。你打算继承的类在其 API 中是否有任何缺陷?如果是这样,你是否愿意将这些缺陷传播到你的子类 API 中?继承会传播父类 API 中的任何缺陷,而组合可以让你设计一个隐藏这些缺陷的新 API。
总而言之,继承很强大,但它是有问题的,因为它违反了封装。仅当子类和父类之间存在真正的子类型关系时才适用。即使这样,如果子类与父类在不同的包中并且父类不是为继承而设计的,继承可能会导致脆弱性。为避免这种脆弱性,请使用组合和转发而不是继承,尤其是在存在实现包装类的适当接口时。包装类不仅比子类更健壮,而且更强大。