继承是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得脆弱。在包的内部使用继承是非常安全的,在那里子类和超类的实现都处于同一个程序员的控制之下。对于专门为了继承而设计并且有很好的文档说明的类来说,使用继承也是非常安全的。然而,对普通的具体类进行跨包边界的继承,则是非常危险的。这里的继承指的是继承类,而不是继承接口。
与方法调用不同的是,继承打破了封装性。换句话说,子类依赖其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使他的代码完全没有改变。因此,子类必须跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并具有很好的文档说明。
为了说明得更加具体一点,我们假设有一个程序使用了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.addAll(c);
}
public int getAddCount(){ return addCount; }
}
这个类看起来非常合理,但是它并不能正常工作。假设我们创建了一个实例,并利用addAll方法添加了三个元素。
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
中的时候,就修正了几个这类性质的安全漏洞。
上面的着两个问题都来自于覆盖方法。你可能会认为在扩展一个类的时候,仅仅增加新的方法,而不覆盖现有的方法是安全的。虽然这种方式比较安全一些,但是也并非完全没有风险。如果超类在后续的发行版本中获得了一个新的方法,并且不幸的是,你给子类提供了一个签名相同当返回类型不同的方法,那么这样的子类将无法通过编译。如果给子类提供的方法带有与新的超类方法完全相同的签名和返回类型,实际上就覆盖了超类中的方法,因此又回到了上述的两个问题。此外,你的方法是否能够遵守新的超类方法的约定,这也是很值得怀疑的,应为当你在编写子类方法的时候,这个约定压根还没面世。
幸运的是,有一种方法可以避免前面提到的所有问题。即不扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称为“复合”,因为现有的类变成了一个新类的一个组件。新类中的每一个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为转发,新类中的方法被称为方法转发。这样得到的类将会非常稳固,它不依赖于现有类的实现细节。即使现有的类添加了新的方法,也不会影响新的类。为了更具体地说明,请看下面地例子,它用复合/转发地方法来代替InstrumentedHashSet
类。注意这个实现分为两部分:类本身和可重用地转发类,其中包含了所有的转发方法,没有任何其他的方法:
public class InstrumentedHashSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedHashSet(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; }
}
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
// 下面都是方法转发
public int size() { return s.size(); }
public boolean isEmpty() { return s.isEmpty();}
public boolean contains(Object o) { return s.contains(o);}
public Iterator<E> iterator() { return s.iterator();}
public Object[] toArray() { return s.toArray();}
public <T> T[] toArray(T[] a) { return s.toArray(a); }
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 retainAll(Collection<?> c) { return s.retainAll(c);}
public boolean removeAll(Collection<?> c) { return s.retainAll(c);}
public void clear() { s.clear(); }
}
Set
接口的存在使得InstrumentedHashSet
类的设计成为可能,因为Set
接口保存了HashSet
类的功能特性。除非获得健壮性之外,这种设计也带来了更多的灵活性。InstrumentedHashSet
类实现了Set
接口,并拥有单个构造器,他的参数也是Set
类型。从本质上讲,这个类把一个Set
转变成立另一个Set
,同时增加了计数的功能。前面提到的基于继承的方法只适用于单个具体的类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,于此不同的是,这里的包装类可以被用来包装任何Set
实现,并且可以结合任何先前存在的构造器一起工作。
Set<Instant> times = new InstrumentedHashSet<>(new TreeSet<>());
Set<E> s = new InstrumentedHashSet<>(new HashSet<>(INIT_CAPACITY));
InstrumentedHashSet
类甚至也可以用来临时替换一个原本没有计数特性的Set
实例
static void walk(Set<Dog> dogs){
InstrumentedHashSet<Dog> iDogs = new InstrumentedHashSet<>(dogs);
...
}
因为每一个InstrumentedHashSet
实例都把另一个Set
实例包装起来了,所以InstrumentedHashSet
类被称为包装类。这也正是Decorator
(修饰者)模式,因为InstrumentedHashSet
类对一个集合进行了修饰,为它增加了计数特性。有时复合和转发的结合也被宽松地称为“委托”。从技术地角度而言,这不是委托,除非包装对象把自身传递给被包装地对象。
包装类几乎没有什么缺点。但需要注意地一点是,包装类不适合用于回调框架;在回调框架中,对象把自身地引用传递给了其他的对象,用于后续的调用。因为被包装起来的对象并不知道他外面的包装对象,所以它传递一个指向自身的引用(this)。回调时避开了外面的包装对象。这被称为SELF问题。有些人担心转发调用所带来的影响,或则包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发倒是优点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包提供。
只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类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.getProperty(key)
就有可能产生与p.get(key)
不同的结果:前一个方法考虑了默认的表属性,而后一个方法则继承自Hashtable
,没有考虑默认的属性列表。最严重的是,客户有可能直接修改超类,从而破坏子类的约束条件。在Properties
的情形中,设计者的目标就只是允许字符串作为键和值,但是直接访问底层的Hashtable
就允许违反这种约束条件。一旦违反了约束条件,就不可能再使用Properties
API的其他部分(load和store)了。等到发现这个问题时,要改正它已经太晚了,因为客户端依赖于使用非字符串的键和值了。
在决定使用继承而不是复合之前,还应该问自己最后一组问题。对于你正在试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把这些缺陷传播到类的API中?继承机制会把超类API中的所有缺陷传播到子类中,而复合则允许设计新的API来隐藏这些缺陷。
简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之前确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种脆弱性,可以使用复合的转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。