相对于继承(inheritance),应更优先使用组合(composition)

继承是一种非常强大的代码重用方式,但它并不总是最好的方式。

在同一个软件包内可以安全地使用继承,这是因为父类和子类都在同一群开发者的维护之下。或者父类是被设计为专用于继承,那么同样可以安全地使用。相反,跨软件包去继承普通的类,则可能是危险的。

不同于方法调用,继承会破坏类的封装。换言之,子类必须依赖于父类的实现细节才能正常运行,而父类的实现完全可能在不同版本之间发生变化,这会破坏子类代码。

比如下面的代码。继承了HashSet类,增加一个计数器,以统计Set中曾加入过的元素总数:

// 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方法加入三个元素:

InstrumentedHashSet<String> s = 
	new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));

预期的结果是3,实际上getAddCount方法却将返回6。这是因为子类覆盖的addAll方法调用了super.addAll,而在父类HashSet中,addAll是通过反复调用add来实现的。所以当子类调用addAll方法时,它本身为计数器增计3次,之后super.addAll通过被子类覆盖的add方法,又增计3次,最后得到了错误结果6。这段代码当然可以修复,但这种修复将或多或少依赖于现有父类HashSet的实现,如果在未来某个版本中,HashSet的行为有所改变,这些修复代码仍然有可能损坏。

为避免继承可能引入的问题,可以使用"组合"这种设计。所谓组合,就是创建一个新的类,并把要重用的已有类的对象作为新类的一个属性。并且新对象可以通过调用其成员对象的方法来实现原有类的功能,这种架构被称为"转发"(forwarding)。相应的,新类中的方法被称为"转发方法"(forwarding methods)。下面的代码是组合版本的"可计数集合":

// 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接口的所有实现,而不必依赖于某个具体的类或构造函数:

Set<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp));
Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));

"组合"设计还有另外好几个名字,类似上面例子中InstrumentedSet那样的类还经常被称为"包装类"(wrapper class),这种架构实际上也是decorator设计模式。不严格地说,组合与转发同时出现时,还可以被称为"代理"(delegation)。但严格说来,"代理"要求包装对象(wrapper object)传递一个指向自身的引用到被包装的对象。

上述wrapper架构少有缺点,首要的瑕疵就是它不适用于"回调"(callback)框架。由于被包装的对象经常无法获得其包装者的信息,所以当它把自身引用传递给其他对象作为回调时,其包装者将在回调过程中被绕过。

继承架构真正适用的场景其实只有一个,那就是当父类与子类有真正逻辑上的同质关系时。比如B类继承了A类,那么要求任何一个B都真的"是"一个A。Java标准库中有很多违背这一原则的反例,比如Stack继承了Vector,Properties继承了Hashtable。这些错误都在现实中带来了很多问题,所以开发者应该尽可能避免这样的设计缺陷。

提交评论


安全码
刷新