继承是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处在同一个程序员的控制之下。对于专门为了继承而设计并且具有很好的文档说明的类来说(详见第19条),使用继承也是非常安全的。然而,对普通的具体类进行跨越包边界的继承,则是非常危险的。提示一下,本书使用“继承”一词,含义是实现继承(当一个类扩展另一个类的时候)。本条目中讨论的问题并不使用于接口继承(当一个类实现一个接口的时候,或者当一个接口扩展另一个接口的时候)。
与方法调用不同的是,继承打破了封装性
。换句话说,子类依赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化。如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。因而,子类必须要跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。
为了说明得更具体一点,我们假设有一个程序使用了HashSet。为了调优程序得性能,需要查询HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素数目混淆起来,它会随着元素的删除而递减)。为了提供这种功能,我们得编写一个HashSet变体,定义记录视图插入的元素的数量addCount,并针对该记数值导出一个访问方法。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创建了一个列表,该方法是在Java9中增加的。如果使用较早的版本,则用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方法,每个元素调用一次。这三次调用又分别给addCount加1,所以总共增加了6:通过addAll方法增加的每个元素都被计算了两次。
我们只要去掉被覆盖的addAll方法,就可以修正这个子类。虽然这样得到的类可以正常工作,但是它的功能正确性则需要依赖于这样的事实:HashSet的addAll方法是在它的add方法上实现的。这种“自用性”是实现细节,不是承诺,不能保证在Java平台的所有实现中都是保持不变的,不能保证随着发行版本的不同而不发生变化。因此,这样得到的InstrumentedHashSet类是非常脆弱的。
稍微好一点的做法是,覆盖addAll方法来遍历指定的集合,为每个元素调用一次add方法。这样做可以保证得到正确的结果,不管HashSet的addAll方法是否在add方法的基础上实现,因为HashSet的addAll实现将不会再被调用到。然而,这项技术并没有解决所有的问题,它相当于重新实现了超类的方法,这些超类的方法可能是自用的,也可能不是,这种方法很困难,也非常耗时,容易出错,并且可能降低性能,此外,这样做并不总是可行的,因为无法访问对于子类来说是私有的域,所以有些方法就无法实现。
导致子类脆弱的一个相关的原因是,它们的超类在后续的发行版本中可以获得新的方法。假设一个程序的安全性依赖于这样的事实:所有被插入某个集合中的元素都满足某个先决条件。下面的做法就可以确保这一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能差入元素的新方法,这种做法就可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将“非法的”元素添加到子类的实例中。这不是一个纯粹的理论问题。在把HashTable和Vector加入到Collections Framework中的时候,就修正了几个这类性质的安全漏洞。
上面这两个问题来源于覆盖方法,你可能会认为在扩展一个类的时候,仅仅增加新的方法,而不覆盖现有的方法是安全的。虽然这种扩展比较安全一些,但是也并非完全没有风险。如果超类在后续的发行版本中获得了一个新的方法,并且不幸的是,你给子类提供了一个签名相同但返回类型不同的方法,那么这样的子类将无法通过编译。如果给子类提供的方法带有与的超类方法相同的签名和返回类型,实际上就是覆盖了超类中的方法,因此又回到了上述两个问题。此外,你的方法是否能够遵守新的超类方法的约定,这也很值得怀疑的,因为当你在编写子类方法的时候,这个约定压根还没有面世
。
幸运的是,有一种办法可以避免前面提到的所有问题。既不扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称为复合,因为现有的类变成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应得方法。这样得到的类将会非常稳固,它不依赖于现有类的实现细节。即使现有类添加了新的方法,也不会影响新的类。为了进行更具体地说明,请看下面地例子:类本身和可重用地转发类,其中包含了所有地转发方法,没有任何其他方法:
// 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接口的存在使得InstrumentedSet类的设计成为可能,因为Set接口保存了HashSet类的功能特性。除了获得健壮性之外,这种设计也带来了更多的灵活性。InstrumentedSet类实现了一个Set接口,并且拥有单个构造器,它的参数也是Set类型。从本质上讲,这个类把一个Set转换成另一个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实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称为包装类(wrapper class)。这也正是Decorator(修饰着)模式,因为InstrumentedSet类对一个集合进行修饰,为它增加了计数特性。有时复合和转发的结合也被宽松地成为委托。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象。
包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用于回调框架;在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象,这被称为SELF问题。有些人担心转发方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒是有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。例如,Guava就为所有的集合接口提供了转发类[Guava]。
只有当子类真正是超类的字类型时,才适合继承。换句话说,对于两个类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会把你限制在原始的实现上,永远限定了类的性能。更为严重的是,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节。这样至少会导致语义上的混淆。例如:如果p指向Properties实例,那么p.getProerty(key)就有可能产生与p.get(key)不同的结果:前一个方法考虑了默认的属性表,而后一个方法则继承自Hashtable,没有考虑默认的属性列表。最严重的是,客户有可能直接修改超类,从而破坏了子类的约束条件。在Properties的情形中,设计者的目标是只允许字符串作为建(key)和值(value),但是直接访问底层的Hashtable就允许违反这种约束条件。一旦违反了约束条件,就不可能再使用Properties API的其他部分(load)和(store)了。等发现这个问题时,要改正它已经太晚了,因为客户端依赖于使用非字符串的键和值了。
在决定使用继承而不是复合之前,还应该问自己最后一个问题,对于你正在试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把这些缺陷传播到类的API中?继承机制会把超类的API中的所有缺陷传播到子类中,而复合则允许设计新的API隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多的问题,因为它违反了封装的原则。只有当子类和超类之间确实存在类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性,为了避免这种脆弱性,可以用复合和转发的机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候,包装类不仅比子类更加健壮,而且功能也更加强大。
网友评论