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