继承是实现代码重用的有力手段,但并非总是最好的选择。因为对于普通的具体类进行跨超包边界的继承则是非常危险的,它打破了封装性。子类信赖于其超类中特定功能的实现细节。超类的实现有可能会随着发行版本的不同而有变化,子类有可能会被破坏。下面是一个例子,扩展HashSet,记录HashSet实例创建以来一共进行了多少次添加元素的操作。
public class InstrumentedHashSet<E> extends HashSet<E> {
private static final long serialVersionUID = 678508745042560741L;
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;
}
}
public class Test {
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<String>();
s.add("1");
System.out.println(s.getAddCount());
s.add("2");
System.out.println(s.getAddCount());
s.add("3");
System.out.println(s.getAddCount());
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
结果:1;2;3;9
导致上述问题的原因是因为在HashSet内部,addAll方法是基于add方法来实现的,为了修改这个问题,可以去掉被覆盖的addAll方法,但是它的功能正确性需要信赖于HashSet的addAll方法是在add方法上实现的这一事实,这种自用性(self-use)是实现细节,而不是承诺,不能保证在java平台的所有实现中都保持不变,不能保证随着上发行版本的不同而不发生变化。因此,这样得到的子类将是非常脆弱的。
导致子类脆弱的原因是,如果父类增加或者移除方法也会对子类产生影响。比如说,子类扩展了某个集合类,覆盖了所有添加元素的方法,在添加元素之前对元素进行检查,让所有元素满足某个条件。如果在后来的版本中,父类增加了新的添加元素的方法,而子类没有覆盖该方法,导致非法元素添加到集合中。那么如果我们只是在子类中对父类进行功能扩展却不重写父类的方法呢?,这样也有可能出现问题。即便父类的实现没有问题,但也可以因为子类实现不当而破坏父类的约束。比如,父类在后续版本中恰好增加了和子类相同签名和返回类型的方法。
使用“复合”方案可以避免以上的所有问题。不用扩展现有的类,而是在新的类中增加一个私有对象,它引用现有对象的一个实例,这种设计被称作为复合,因为现有的类变成了新类的一个组件,新类中的每个实力方法都可以调用被包涵的现有类实例中对应的方法,并返回它的结果,这称为转发,新类中的方法被称为转发方法。这样得到的类将会非常稳固。它不信赖于现有类的实现细节。即使现有的类增加了新方法,也不会影响到新类。这种实现包含了两部分:类本身和可重用的转发类,包含了所有的转发方法,没有其他方法。
public class ForwardingSet<E> implements Set<E>{
private final Set<E> s;
public ForwardingSet(Set<E> s) {this.s = s;}
@Override
public int size() {return s.size();}
@Override
public boolean isEmpty() {return s.isEmpty();}
@Override
public boolean contains(Object o) {return s.contains(o);}
@Override
public Iterator<E> iterator() {return s.iterator();}
@Override
public Object[] toArray() {return s.toArray();}
@Override
public <T> T[] toArray(T[] a) {return s.toArray(a);}
@Override
public boolean add(E e) {return s.add(e);}
@Override
public boolean remove(Object o) {return s.remove(o);}
@Override
public boolean containsAll(Collection<?> c) {return s.containsAll(c);}
@Override
public boolean addAll(Collection<? extends E> c) {return s.addAll(c);}
@Override
public boolean retainAll(Collection<?> c) {return s.retainAll(c);}
@Override
public boolean removeAll(Collection<?> c) {return s.removeAll(c);}
@Override
public void clear() {s.clear();}
}
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;
}
}
Set接口的存在使得InstrumentedSet类的设计成为可能,因为Set接口保存了HashSet类的功能特性。除了获得健壮性外,这种设计也带来了灵活性。InstrumentedSet类实现了Set接口,并拥有单个构造器,它的参数也是Set类型,从本质上来将,这个类把一个Set转变成了另一个Set,同时增加了计数的功能。前面提到的基于继承的方法只适用与单个具体的类,并且对于超类中所支持的每个构造器都要求有一个单独的构造器,于此不同的是这里的包装类可以用来包装任何Set实现,并且可以结合任何以前存在的构造器一起工作。例如:
Set<Date> s = new InstrumentedHashSet<Date>(new TreeSet<Date>());
Set<E> s2 = new InstrumentedHashSet<E>(new HashSet<E>());
因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所有InstrumentedSet类被称为包装类。这也正是装饰器模式,因为InstrumentedSet类对一个集合进行了装饰,为它增加了计数特性。有的时候,复合和转发的结合被错误的称为"委托"。从技术的角度来说,这不是委托,除非包装对象把自身传递给被包装的对象。
包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用在回调框架上,在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象。
只有当子类真正是超类的子类型(subtype)时,才适合用继承。对于两个类A和B,只有当两者之间确实存在"is-a"的关系的时候,类B才应该扩展A。如果打算让类B扩展类A,就应该确定一个问题:B确实也是A吗?如果不能确定答案是肯定的,那么B就不应该扩展A。如果答案是否定的,通常情况下B应该包含A的一个私有实例,并且暴露一个较小的、较简单的API:A本质上不是B的一部分,只是它的实现细节而已(使用API的客户端无需知道)。
如果在适合于使用复合的地方使用了继承,则会不必要地暴露实现细节。这样得到的API会把你限制在原始的实现上。永远限定了类的性能。更为严重的是,由于暴露了内部细节,客户端就有可能直接访问这些内部细节。这样至少会导致语言上的混乱。最严重的,客户有可能直接修改超类,从而破坏了子类的约束条件。比如说Properties的实例,设计者的意图是只允许字符串作为键和值,但是如果直接去访问它的超类Hashtable就可以违反这个约束。
在决定使用继承而不是复合之前,还应该问自己最后一组问题:对于你正试图扩展的类,它的API中有没有缺陷呢?如果有,你是否愿意把它们传播到类的API中?继承机制会把超类API中的所有缺陷传播到子类中(除非你愿意重写一遍改进版的超类),而复合则允许设计亲的API来隐藏这些细节。
简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违反了封装原则。只有当子类和超类之间确实存在子类型的关系时,使用继承才是恰当的。即使如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种情况,可以使用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更强大。