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