继承是实现代码复用的强大的方式,但是它在工作上不总是最适合的工具。使用不当,会导致软件脆弱。在同一个包中使用继承是安全的,其中子类和超类的实现是在相同的程序员的控制下。在专门为扩展而设计和记录的类扩展时,使用继承也是安全的 (item19) 。然而,跨越包边界的普通具体类的继承是危险的。在此提醒,本书使用单词“inheritance”表示实现继承(一个类继承另一个)。这个条目中讨论的问题不适用于接口继承(当一个类继承一个接口或一个接口继承另一个接口)。
与方法调用不同,继承违反了封装[Snyder86]。换句话说,子类依赖于它的超类的实现细节来实现正确功能。超类的实现可能在不同版本之间发生变化,如果确实如此,子类就有可能被破坏,即使它的代码未被触及。因此,子类必须随着其超类一起演变,除非超类的作者为了扩展而专门设计和记录它。
为了使之具体化,让我们假定我们有一个程序使用HashSet。为了调整我们的程序的性能,我们需要查询HashSet用于查询自其创建时有多少元素被添加(不要混淆认为是当前大小,这当元素被删除时会减少)。为了提供这个功能,我们编写了一个HashSet变种,它保持了元素插入的次数的计数,并为这个计数导出一个访问器。HashSet类包含了两个添加元素的方法add和addAll,所以我们重写这两个方法:
// 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方法添加了三个元素。顺便提一下,注意到我们使用Java9中的静态工厂方法List.of创建一个列表;如你你使用更早的版本,使用Arrays.asList代替:
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调用了HashSet的addAll实现。这反过来调用了InstrumentedHashSet覆写的add方法,每元素加1。这三次调用中的每次调用都在addCount中加了1,总共就增加了6:使用addAll方法添加的每个元素都被重复计算了。
我们可以通过去掉addAll方法的覆写来“修复”这个子类。虽然生成的类可以正常工作,但是它还是依赖于HashSet的addAll方法在其add方法之上实现的正确功能。这种“自我使用”是一个实现细节,并不能保证在Java平台的所有实现中都能保留,并且随着版本的不同而发生变化。因此,生成的InstrumentedHashSet类是脆弱的。
覆写addAll方法来迭代指定的集合,为每个元素调用一次add方法会稍微好一点。无论HashSet的addAll方法是否基于它的add方法,这将会保证正确的结果,因为HashSet的addAll方法的实现将不再被调用。然而,这种技术并没有解决我们的所有的问题。它相当于重新实现了超类的可能导致或可能不导致自我使用的方法,这是困难的,耗时的,容易出错的,可能降低性能。此外,它并不总是可行的,因为一些方法在没有访问子类私有字段的情况下是不能实现的。
子类脆弱性的一个相关原因是它们的超类可以在后续版本中获取新方法。假设程序依赖于其安全性,基于所有元素插入一些集合满足某些断言。这可以通过继承集合覆写每个能添加元素的方法来保证,以保证在添加元素之前满足断言。这可以正常工作直到在后续版本中超类有新的方法能将元素插入。一旦发生这种情况,仅通过调用新方法就可以添加“非法”元素,而子类不会覆写该方法。这不是一个纯理论问题。当Hashtable和Vector被改造以参与集合框架时,一些这种性质的安全漏洞就必须要被修复。
这些问题都出自覆写方法。你可能会认为如果你仅仅添加新方法并避免覆写已存在方法,就能安全地扩展类。虽然这种扩展更加安全,但是并非没有风险。如果超类在后续版本中需要一个新方法,并且你不幸给了子类一个相同的签名和不同返回类型的方法,那么你的子类将不再编译[JLS, 8.4.8.3]。如果你给了子类和超类新方法相同前面相同返回类型的方法,那么你就要覆写它,就会遇到前面描述的问题。此外,你的方法将满足新的超类方法的契约是值得怀疑的,因为在编写子类方法时并未编写该契约。
幸运的是,有一种方法可以避免上述的所有问题。不是扩展现有类,而是为新类提供引用现有类实例的一个私有字段。这种设计称为组合,因为现有类成为了新类的一个组件。新类中的每个实例方法都在现有类的包含实例上调用相应的方法并返回结果。这称为转发,新类中的方法被称为转发方法。结果类将是坚若磐石,不依赖于现有类的实现细节。即使在现有类中添加新方法,也不会对新类产生影响。为了具体化,这里是使用组合和转发方法的InstrumentedHashSet的替代。请注意,实现分为两部分,类本身和可重用的转发类,其中包含所有的转发方法,不包含其他任何:
// Wrapper class - uses composition in place of inheritance
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;
}
}
// Reusable forwarding class
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);
... // Within this method use iDogs instead of dogs
}
InstrumentedSet类亦可称为包装类,因为每个InstrumentedSet实例包含(“包装”)另一个Set实例。这也被称为装饰器模式,因为InstrumentedSet类通过添加检测“装饰”了一个集合。有时候,组合和转发的组合被宽泛地被称为委托。从技术上将,这不是委托,除非包装器对象将自身传递给包装对象[Lieberman86; Gamma95]。
包装类的缺点不多。提醒的是,包装类不适合在回调框架中使用,其中对象将自引用传递给其他对象以进行后续调用(“回调”)。因为包装对象不知道它的包装器,他会传递对他自己的引用(this),并且回调会逃避包装器。这被称为SELF问题[Lieberman86]。有些人担心转发方法调用对性能的影响或包装器对象的内存占用影响。结果在实践中都没有太大影响。编写转发方法很繁琐,但是你必须为每个接口编写一次可重用转发类,并且可以提供转发类,例如,Guava为所有集合接口提供转发类[Guava]。
只有在子类确实是超类的子类型的情况下,继承才是合适的。换句话说,类B应该继承A有且仅当在这两个类中有“is-a”关系存在。如果你想让B扩展A,问问你自己一个问题:每个B真的都是A吗?如果你不能如实回答yes,B就不该继承A。如果答案是no,通常情况下B应该包含A的一个私有实例并暴露不同的API:A不是B的必要部分,仅仅只是它实现的细节。
Java平台库有许多明显违反规定的行为。例如,堆栈不是向量,因此Stack不该扩展Vector。同样,属性列表不是哈希表,所以属性不能扩展Hashtable,在这两种情况下,组合是优选的。
如果在适合组合的地方使用继承,你将会不必要地暴露实现细节。生成的API将使你和原始实现联系绑定,永远限制你的类的性能。更严重的是,通过暴露内部,你可以让客户端直接访问。至少,它可能会导致语义混乱。例如,如果p引用Properties实例,则p.getProperty(key)可能会产生与p.get(key)不同的结果:前一种方法将默认值考虑在内,后一种方法从hashtable继承。最严重的是,客户端可能会通过直接修改超类来破坏子类的不变量。在Properties的情况下,设计者希望只允许字符串作为键和值,但是对底层hashtable的直接访问允许不违反这个不变量。一旦违反,就不能再使用Properties API的其他部分(加载和存储)。当发现这个问题时,由于客户端依赖于使用非字符串键和值,因为纠正他为时已晚。
在决定使用继承代替组合之前,你应该问自己最后一组问题,你可以考虑扩展的类在其API中是否存在任何缺陷?如果是这样,你是否原因将这些缺陷传播到你类的API中?继承传播超类API中任何缺陷,而组合允许你设计隐藏这些一些的API。
总而言之,继承是强大的,但是
它也是有问题的,因为它违反了封装。只有当子类和超类之间存在真正的关系时才适用。即使这样,如果子类与超类位于不同的包中,并且超类不是为了继承而设计的,那么继承可能会导致脆弱。为了避免这些脆弱性,请使用组合和转发而不是继承,尤其是存在实现包装类的适当接口的情况下。超类类不仅比子类更健壮,而且功能更强大。
本文写于2019.4.5,历时4天