Effective Java 第三版读书笔记——条款18:组合优于继承

继承是实现代码重用的有效方式,但并不总是最好的工具。使用不当,会导致脆弱的软件。在一个包的内部使用继承是安全的,因为子类和父类的实现都在同一个程序员的控制之下。然而,从普通的具体类跨越包级边界继承是危险的。提醒一下,本书使用“继承”一词来表示实现继承(当一个类继承另一个类时)。在这个条款中讨论的问题不适用于接口继承(当类实现接口或当接口继承另一个接口时)。

与方法调用不同,继承打破了封装。换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。父类的实现可能会因发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。因此,一个子类必须与其父类一起更新。

为了具体说明,假设有一个使用 HashSet 的程序。 为了调整程序的性能,需要查询 HashSet 从创建之后已经添加了多少个元素(不要和当前的元素数量混淆,当元素被删除时当前元素数量会下降)。为了提供这个功能,我们编写了一个 HashSet 变体,它持续统计插入元素的数量,并提供对这个数量的 get 方法。 HashSet 类包含两个添加元素的方法,分别是 addaddAll ,所以我们重写这两个方法:

// 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 方法添加三个元素。顺便提一句,下面代码使用在 Java 9 中添加的静态工厂方法 List.of 来创建一个列表;如果使用的是早期版本,请改为使用 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 方法调用了 HashSetaddAll 实现。然后反过来又调用了 InstrumentedHashSet 类中重写的 add 方法,每个元素调用一次。这三次调用又分别给 addCount 加1,所以,一共增加了 6:通过 addAll 方法每个增加的元素都被计算了两次。

这是导致子类脆弱的一个原因:父类方法的“自我使用”。另一个原因是父类在后续的版本中添加新的方法。

这两个问题都源于重写方法。你可能认为如果仅仅添加新的方法并且不要重写现有的方法,那么继承一个类是安全的。虽然这种扩展更为安全,但这并非没有风险。如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同参数和不同返回类型的方法,那么你的子类编译失败。如果已经为子类提供了一个与新的父类方法具有相同参数和相同返回类型的方法,那么你现在就会变成正在重写这个父类方法,因此将遇到前面所述的两个问题。

幸运的是,有一种方法可以避免上述所有的问题。不要继承一个现有的类,而应该给你的新类增加一个私有属性,该属性是对现有类的实例的引用,这种设计被称为组合(composition),因为现有的类成为新类的组成部分。新类中的每个实例方法调用现有类的实例上的相应方法并返回结果。这被称为转发(forwarding),而新类中的方法被称为转发方法。由此产生的类将坚如磐石,不依赖于现有类的实现细节。即使将新的方法添加到现有的类中,也不会对新类产生影响。为了具体说明,下面代码使用组合和转发方法替代 InstrumentedHashSet 类。请注意,实现分为两部分,类本身和一个可重用的转发类。转发类包含所有的转发方法,没有别的方法:

// 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(); }
}

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 类被称为包装类,因为每个 InstrumentedSet 实例都包含(“包装”)另一个 Set 实例。 这也被称为装饰器模式,因为 InstrumentedSet类通过添加计数功能来“装饰”一个 Set。 有时组合和转发的结合被不精确地地称为委托(delegation)。从技术上讲,除非包装对象把自身传递给被包装对象,否则不是委托。

包装类的缺点很少。一个警告是包装类不适合在回调框架(callback frameworks)中使用——其中对象将自我引用传递给其他对象以用于后续调用(“回调”)。因为一个被包装的对象不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。

只有在子类真的是父类的子类型的情况下,继承才是合适的。换句话说,只有在两个类之间存在“is-a”关系的情况下,B 类才能继承 A 类。如果你试图让 B 类继承 A 类,问自己这个问题:每个 B 都是 A 吗? 如果你不能很确信地回答是,那么 B 就不应该继承 A。如果答案是否定的,那么 B 通常包含一个 A 的私有实例,并且显露一个不同的 API:A 不是 B 的重要部分 ,只是其实现细节。

在决定使用继承来代替组合之前,你应该问自己最后一组问题。对于你试图继承的类,它的 API 有没有缺陷呢? 如果有,你是否愿意将这些缺陷传播到你的类的 API 中?继承会传播父类的 API 中的任何缺陷,而组合可以让你设计一个隐藏这些缺陷的新 API。

总之,继承是强大的,但它是有问题的,因为它违反封装。只有在子类和父类之间存在真正的子类型关系时才适用。即使如此,如果子类与父类不在同一个包中,并且父类不是为继承而设计的,继承都很有可能导致脆弱性。为了避免这种脆弱性,使用组合和转发代替继承,特别是如果存在一个合适的接口来实现包装类。包装类不仅比子类更健壮,而且更强大。

猜你喜欢

转载自blog.csdn.net/sky_asd/article/details/86535870