继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项任务的最佳工作。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处于同一个程序员的控制下。对于专门为了继承而设计的并且具有很好的文档说明的类来说,使用继承也是非常安全的。然而,对于普通的具体类进行跨超包边界的继承则是非常危险的。
与方法调用不同的是,继承打破了封装性。子类信赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了这种变化,子类有可能会被破坏,即使它的代码完全没有改变。
// 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(Collections extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
这个类非常合理,但是他不能正常工作。
例:
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("a", "b", "c");
我们期望getAddCount方法将会返回3,但结果却是6,原因是在HashSet内部,addAll方法是基于它的add方法实现的,InstrumentedHashSet的addAll方法首先给addCount增加3,然后利用super.addAll调用HashSet的addAll实现,然后有此次调用到被InstrumentedHashSet覆盖了的add方法,每个元素调用一次,每次调用增加1,所有总共增加6。
只要去掉覆盖的addAll方法,就可以“修正”这个子类,使其正常工作,但是HashSet的addAll方法会随着版本更新而改变。
因此InstrumentedHashSet类,是脆弱的。
导致子类脆弱的一个相关的原因是:它们的超类在后续的发行版本中可以获得新的方法,假设一个程序的安全性信赖于这样的事实:所有被插入到某个集合的元素都满足某个先决条件。下面的做法就可以确保这一点:对集合进行子类化,并覆盖所有能够添加元素的方法以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能插入元素的新方法,那么这种方法可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将不合法的元素添加到子类的实例中。
这两个问题的来源都是因为“覆盖”。如果在扩展一个类的时候仅仅是增加新的方法而不覆盖现有的方法,这也许看来相对安全一些,但是设想一下,如果超类在后续的发行版本中获得了一个新方法,并且和子类中的某一方法只是返回类型不同,那么这样的子类将针法通过编译。如果给子类提供的方法带有与新的超类方法完全相同的方法(签名和返回类型都相同),这又变成了子类覆盖超类的方法问题。此外,子类的方法是否则够遵守新的超类的方法的约定也是个值得怀疑的问题,因为当编写子类方法的时候,这个约定根本还没有面世。
解决方法:
使用“复合(composition)”,不用扩展现有的类,而是在新的类中增加一个私有域。
// Wrapper class - uses composition in place of inheritance
public class InstrumentedSet extends ForwardingSet {
private int addCount = 0;
public InstrumentedSet(Set 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 implements Set {
private final Set s;
public ForwardingSet(Set 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 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[] 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接口的存在使得INstrumentedSet类的设计成为可能,因为Set接口保存了HashSet类的功能特性。除了获得健壮性之外,这种设计也带来了格外的灵活性。InstrumentedSet类实现了Set接口,并且拥有单个构造器,它的参数也是Set类型。从本质上讲,这个类把一个Set转变成了另一个Set,同时增加了计数的功能。前面提到的基于继承的方法只适用于单个具体的类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,与此不同的是,这里的包装类(wrapper class)可以被用来包装任何Set实现,并且可以结合任何先前存在的构造器一起工作。例如:
Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp));
Set s2 = new InstrumentedSet(new HashSet(capacity));
InstrumentedSet甚至也可以用来临时替换一个原本没有计数特性的Set实例:
static void walk(Set dogs) {
InstrumentedSet iDogs = new InstrumentedSet(dogs);
... // within this method use iDogs instead of dogs
}
因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称做包装类(wrapper class)。这也正是Decorator模式,因为InstrumentedSet类对一个集合进行了修饰,为他增加了计数特性。有时候,复合和转发的结合也被错误的称为“委托(delegation)”。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象。
包装类几乎没有什么缺点。需要注意的一点是,包装类不合适用在回调框架(callback framework)中;在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象。这被称为SELF问题。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒是有些琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包替你提供。
只有当子类真正是超类的子类型(subtype)时,才适合继承。换句话说,对于两个类A和B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展A。如果你打算让B扩展A,就应该问问自己:每个B确实也是A吗?如果你不能够确定这个问题的答案是肯定的,那么B就不应该扩展A。如果答案是否定的,通常情况下,B应该包含A的一个私有实例,并且暴露一个较小的、叫简单的API:A本质上不是B的一部分,只是他的实现细节而已。
在Java平台类库中,有许多明显违反这条原则的地方。例如,栈(stack)并不是向量(vector),所以Stack不应该扩展Vector。同样的,属性列表也不是散列表,所以Properties不应该扩展Hashtable。在这两种情况下,复合模式才是恰当的。
如果在适合于使用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的API会把你限制在原始的实现上,永远限定了类的性能。更为严重的说,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节。这样至少会导致语义上的混淆。
在决定使用继承而不是复合之前,还应该问自己最后一组问题。对于你正试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把那些缺陷传播到类的API中?继承机制会把超类API中的所有缺陷传递到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多问题。因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性(fragility)。为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当地接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。