119 Star 1K Fork 296

lin-mt / effective-java-third-edition

加入 Gitee
与超过 1200万 开发者一起发现、参与优秀开源项目,私有仓库也完全免费 :)
免费加入
克隆/下载
第18项:复合优先于继承.md 15.05 KB
一键复制 编辑 原始数据 按行查看 历史
lin-mt 提交于 2020-06-08 22:45 . 修改部分超链接

复合优先于继承

  继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序猿的控制之下。对于专门为了继承而设计、并且具有很好的文档说明的类来说(第 19 项),使用继承也是非常安全的。然而,跨越包边界,从普通的具体类(concrete class)继承,则是非常危险的(Inheriting from ordinary concrete classes across package boundaries,however, is dangerous)。提示一下,本书使用“继承”一词,含义是实现继承(implementation inheritance)(当一个类扩展另一个类的时候)。本项中讨论的问题并不适用于接口继承(interface inheritance)(当一个类实现一个接口的时候,或者当一个接口扩展另一个接口的时候)。

  与方法调用不同的是,继承打破了封装性[Snyder86]。换句话说,子类依赖于其超类中特定功的功能实现。超类的实现有可能会随着版本的不同而有所变化,如果真的发生了变化,子类可能会遭受到破坏,即使它的代码完全没有改变。因而,子类必须跟着其超类的更新而演变,除非超类是专门为了扩展而设计的,并且具有很好的文档说明。

  为了说明得更加具体一点,我们假设有一个程序使用了 HashSet。为了调优该程序的性能,需要查询 HashSet,看一看自从它被创建以来添加了多少个元素(不要与它当前的元素混淆起来,元素数目会随着元素的删除而递减)。为了提供这种功能,我们得编写一个 HashSet 变量,它记录下试图插入的元素数量,并针对该计数值导出一个访问方法。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 方法添加三个元素。顺便提一下,请注意我们使用静态工厂方法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,然后利用 super.addAll 来调用 HashSet 的 addAll 实现。然后又一次调用到被 InstrumentedHashSet 重写的 add 方法,每个元素调用一次。这三次调用又分别给 addCount 加了 1,所以总共增加了 6:通过 addAll 方法增加的元素都别计算了两次。

  我们只要去掉被重写的 addAll 方法,就可以“修正”这个子类。虽然这样得到的类可以工作,但它依赖于这样的事实:HashSet 的 addAll 方法是在它的 add 方法上实现的。这种“自用性(self-use)”是一个实现细节,不能保证在 Java 平台的所有实现中都保持不变,不能保证随着发行版本的不同而不发生变化。因此,这样得到的 InstrumentedHashSet 类将是非常脆弱的。

  稍微好一点的做法是,覆盖 addAll 方法来遍历指定的集合,为每个元素调用一次 add 方法。这样做可以保证得到正确的结果,不管 HashSet 的 addAll 方法是否是在 add 方法的基础上实现的,因为 HashSet 的 addAll 实现将不会再被调用到。然而,这项技术没有解决所有的问题,它几乎重新实现了超类的方法,这些超类的方法可能是自用的(self-use),也可能不是自用的,这种方法很困难,也非常耗时,容易出错,并且有可能导致性能问题。此外,这样做并不总是可行的,因为无法访问对于子类来说的私有域,所以有些方法就无法实现。

  导致子类脆弱的一个相关原因是,它们的超类在后续的发行版本中可以获得新的方法。假设一个程序的安全性依赖于这样的事实:所有被插入到某个集合的元素都满足某个先决条件。下面的做法就可以保证这一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的发行版本中,超类中没有增加能够插入元素的新方法,这种做法就可以正常工作。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被子类覆盖的新方法,而将“非法的”元素添加到子类的实例中。这不是个纯粹的理论问题。在把 Hashtable 和 Vector 加入到 Collections Framework 中的时候,就修正了这类性质的安全漏洞。

  上面这两个问题都来源于重写方法。如果在扩展一个类的时候,仅仅是增加新的方法,而不是重写现有的方法,你可能会认为这是安全的。虽然这种扩展方式比较安全一些,但是也并非完全没有风险。如果超类在后续的发行版本中获得了一个新的方法,并且不幸的是,你给子类提供了一个签名相同但返回类型不同的方法,那么这样的子类将无法通过编译[JLS, 8.4.8.3]。如果子类提供的方法带有与新的超类方法完全相同的签名和返回类型,实际上就重写了超类的方法,因此又回到上述的两个问题上去了。此外,你的方法是否能够遵守新的超类方法的约定,这也是很值得怀疑的,因为当你在编写子类方法的时候,这个约定还没有编写出来。

  很幸运的是,有一种办法可以避免前面提到的所有问题。不用扩展现有的类,而是在新的类中增加一个私有字段,它引用现有类的一个组件。这种设计被称作组合(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 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 接口的存在使得 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 类被称作包装类(The InstrumentedSet class is known as a wrapper class because each InstrumentedSet instance contains (“wraps”) another Set instance)【一个 InstrumentedSet 实例可以看做是一个 Set 实例】。这也正是 Decorator 模式[Gamma95, p.175],因为 InstrumentedSet 类对一个集合进行了修饰,为它增加了计数特性。有时候,组合和转发的结合也被错误地称为“委托(delegation)”。从技术的角度而言,这不是委托,除非包装对象把自身传递给被包装的对象[Lieberman86; Gamma95]。

  包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用在回调框架(callback framework)中,在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象。这被称为 SELF 问题[Lieberman86]。有些人担心重定向方法调用所带来的性能影响,或者包装对象导致的内存占用。在实践中,这两者都不会造成很大的影响。编写重定向方法倒是有点琐碎,但是你必须为每个接口编写一次可重用的重定向类,并且【有些包】可以为您提供重定向类。例如,Guava 为所有集合接口提供转发类[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 不应该扩展 Hashtable。在这两种情况下,组合模式才是恰当的。

  如果在适合使用组合的地方使用了继承,则会不必要地暴露实现细节。这样得到的 API 会把你限制在原始的实现上,永远限定了类的性能。更为严重的是,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节。这样至少会导致语义上的混淆。例如,如果 p 指向 Properties 实例,那么p.getProperties(key)就有可能产生与p.get(key)不同的结果:前者考虑了默认的属性表,而后者是继承自 Hashtable 的,它则没有考虑默认属性列表。最严重的是,客户有可能直接修改超类,从而破坏子类的约束条件。在 Properties 的情形中,设计者的目标是,只允许字符串作为键(key)和值(value),但是直接访问底层的 Hashtable 就可以违反这种约束条件。一旦违反了约束条件,就不可能再使用 Properties API 的其他部分(load 和 store)了。等到发现这个问题时,要改正它已经太晚了,因为客户端依赖于使用非字符串的键和值了。

  在决定使用继承而不是组合之前,还应该问自己最后一组问题。对于你正试图扩展的类,它的 API 中有没有缺陷呢?如果有,你是否愿意把那些缺陷传播到类的 API 中?继承机制会把超类 API 中的所有缺陷传播到子类中,而组合则允许设计新的 API 来隐藏这些缺陷。

  简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类位于不用的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性(fragility)【继承会导致类更加脆弱】。为了避免这种脆弱性,可以使用组合和重定向机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更加强大。

其他
1
https://gitee.com/lin-mt/effective-java-third-edition.git
git@gitee.com:lin-mt/effective-java-third-edition.git
lin-mt
effective-java-third-edition
effective-java-third-edition
master

搜索帮助