18. 组合优于继承

      与方法调用不同,继承打破了封装[Snyder86]。 换句话说,一个子类依赖于其父类的实现细节来保证其正确的功能。 父类的实现可能会从发布版本不断变化,如果是这样,子类可能会被破坏,即使它的代码没有任何改变。 因此,一个子类必须与其超类一起更新而变化,除非父类的作者为了继承的目的而专门设计它,并对应有文档的说明。

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

      这个类看起来很合理,但是不能正常工作。 假设创建一个实例并使用 addAll 方法添加三个元素。 顺便提一句,请注意,下面代码使用在 Java 9 中添加的静态工厂方法 List.of 来创建一个列表;如果使用的是早期版本,请改为使用 Arrays.asList

    1. InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
    2. 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 方法每个增加的元素都被计算了两次。

      我们可以通过消除 addAll 方法的重写来“修复”子类。 尽管生成的类可以正常工作,但是它依赖于它的正确方法,因为 HashSetaddAll 方法是在其 add 方法之上实现的。 这个“自我使用(self-use)”是一个实现细节,并不保证在 Java 平台的所有实现中都可以适用,并且可以随发布版本而变化。 因此,产生的 InstrumentedHashSet 类是脆弱的。

      导致子类脆弱的一个相关原因是,它们的父类在后续的发布版本中可以添加新的方法。假设一个程序的安全性依赖于这样一个事实:所有被插入到集中的元素都满足一个先决条件。可以通过对集合进行子类化,然后并重写所有添加元素的方法,以确保在添加每个元素之前满足这个先决条件,来确保这一问题。如果在后续的版本中,父类没有新增添加元素的方法,那么这样做没有问题。但是,一旦父类增加了这样的新方法,则很有可能由于调用了未被重写的新方法,将非法的元素添加到子类的实例中。这不是个纯粹的理论问题。在把 HashtableVector 类加入到 Collections 框架中的时候,就修复了几个类似性质的安全漏洞。

      这两个问题都源于重写方法。 如果仅仅添加新的方法并且不要重写现有的方法,可能会认为继承一个类是安全的。 虽然这种扩展更为安全,但这并非没有风险。 如果父类在后续版本中添加了一个新的方法,并且你不幸给了子类一个具有相同签名和不同返回类型的方法,那么你的子类编译失败[JLS,8.4.8.3]。 如果已经为子类提供了一个与新的父类方法具有相同签名和返回类型的方法,那么你现在正在重写它,因此将遇到前面所述的问题。 此外,你的方法是否会履行新的父类方法的约定,这是值得怀疑的,因为在你编写子类方法时,这个约定还没有写出来。

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

    1. // Wrapper class - uses composition in place of inheritance
    2. import java.util.Collection;
    3. import java.util.Set;
    4. public class InstrumentedSet<E> extends ForwardingSet<E> {
    5. private int addCount = 0;
    6. super(s);
    7. }
    8. @Override public boolean add(E e) {
    9. addCount++;
    10. return super.add(e);
    11. }
    12. @Override public boolean addAll(Collection<? extends E> c) {
    13. addCount += c.size();
    14. }
    15. public int getAddCount() {
    16. return addCount;
    17. }
    18. }

       类的设计是通过存在的 Set 接口来实现的,该接口包含 HashSet 类的功能特性。除了功能强大,这个设计是非常灵活的。InstrumentedSet 类实现了 Set 接口,并有一个构造方法,其参数也是 Set 类型的。本质上,这个类把 Set 转换为另一个类型 Set, 同时添加了计数的功能。与基于继承的方法不同,该方法仅适用于单个具体类,并且父类中每个需要支持构造方法,提供单独的构造方法,所以可以使用包装类来包装任何 Set 实现,并且可以与任何预先存在的构造方法结合使用:

      InstrumentedSet 类甚至可以用于临时替换没有计数功能下使用的集合实例:

    1. static void walk(Set<Dog> dogs) {
    2. InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs);
    3. ... // Within this method use iDogs instead of dogs
    4. }

      包装类的缺点很少。 一个警告是包装类不适合在回调框架(callback frameworks)中使用,其中对象将自我引用传递给其他对象以用于后续调用(「回调」)。 因为一个被包装的对象不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时并不记得外面的包装对象。 这被称为 SELF 问题[Lieberman86]。 有些人担心转发方法调用的性能影响,以及包装对象对内存占用。 两者在实践中都没有太大的影响。 编写转发方法有些繁琐,但是只需为每个接口编写一次可重用的转发类,并且提供转发类。 例如,Guava 为所有的 Collection 接口提供转发类[Guava]。

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

      在 Java 平台类库中有一些明显的违反这个原则的情况。 例如,stacks 实例并不是 vector 实例,所以 Stack 类不应该继承 Vector 类。 同样,一个属性列表不是一个哈希表,所以 Properties 不应该继承 Hashtable 类。 在这两种情况下,组合方式更可取。

      如果在合适组合的地方使用继承,则会不必要地公开实现细节。由此产生的 API 将与原始实现联系在一起,永远限制类的性能。更严重的是,通过暴露其内部,客户端可以直接访问它们。至少,它可能导致混淆语义。例如,属性 p 指向 Properties 实例,那么 p.getProperty(key)p.get(key) 就有可能返回不同的结果:前者考虑了默认的属性表,而后者是继承 Hashtable 的,它则没有考虑默认属性列表。最严重的是,客户端可以通过直接修改超父类来破坏子类的不变性。在 Properties 类,设计者希望只有字符串被允许作为键和值,但直接访问底层的 Hashtable 允许违反这个不变性。一旦违反,就不能再使用属性 API 的其他部分(loadstore 方法)。在发现这个问题的时候,纠正这个问题为时已晚,因为客户端依赖于使用非字符串键和值了。

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