第16条:复合优于继承

继承(inheritance)是实现代码重用的有力手段,但它并非永远是完成这项工作的最佳工具。使用不当会导致软件变得很脆弱。在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下。对于专门为了继承而设计、并且具有很好的文档的类来说(见第17条),使用继承也是非常安全的。然而,对普通的具体类(concrete class)进行跨越包边界的集成,则是非常危险的。提示一下,本书使用“继承”一词,含义是实现继承(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方法添加了三个元素:

InstrumentedHashSet<String> s =
    new InstrumentedHashSet<String>();
s.addAll(Arrays.asList("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类将是非常脆弱的。

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

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

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

幸运的是,有一种办法可以避免前面提到的所有问题。不用扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称作“复合(composition)”,因为现有的类变成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为转发(forwarding),新类中的方法被称为转发方法(forwarding method)。这样得到的类将会非常稳固,它不依赖于现有类的实现细节。即使现有类添加了新的方法,也不会影响新的类。为了进行更具体的说明,请看下面的例子,它用复合/转发的方法来代替InstrumentedHashSet类。注意这个实现分为两部分:类本身和可重用的转发类(forwarding class),包含了所有的转发方法,没有其他方法。

// Wrapper class - uses compsition 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 retrainAll(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<Date> s = new InstrumentedSet<Date>(new TreeSet<Date>(cmp));
Set<E> s2 = new InstrumentedSet<E>(new HashSet<E>(capacity));

InstrumentedSet类甚至也可以用来临时替换一个原本没有技术特性的Set实例:

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

因为每一个InstrumentedSet实例都把另一个Set实例包装起来了,所以InstrumentedSet类被称作包装类(wrapper class)。这也正是Decorator模式[Gamma95, p.175],因为InstrumentedSet类对一个集合进行了修饰,为它增加了技术特性。有时候,复合和转发的结果也被错误地成为“委托(delegation)”。从技术的角度而言,这不是委托,除非包装对向把自身传递给被包装的对象[Gamma95,p.20]。

包装类几乎没有什么缺点。需要注意的一点是,包装类不适合用在回调框架(callback framework)中;在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用(“回调”)。因为被包装起来的对象并不知道它外面的包装对象,所以它传递一个指向自身的引用(this),回调时避开了外面的包装对象。这被称为SELF问题[Lieberman86]。有些人但系转发方法调用所带来的性能影响,或者包装对向导致的内存占用。在实践中,这两者都不会造成很大的影响。编写转发方法倒是有点琐碎,但是只需要给每个接口编写一次构造器,转发类则可以通过包含接口的包替你提供。

只有当子类真正是超类的子类型(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.getProperty(key)就有可能产生与p.get(key)不同的结果:前者考虑了默认的属性表,而后者是继承自Hashtable的,它则没有考虑默认属性列表。最严重的是,客户有可能直接修改超类,从而破坏子类的约束条件。在Properties的情形中,设计者的目标是,只允许字符串作为键(key)和值(value),但是直接访问底层的Hashtable就可以违反这种约束条件。一旦违反了约束条件,就不可能再使用PropertiesAPI的其他部分(loadstore)了。等到发现这个问题时,要改正它已经为时太晚了,因为客户端依赖于使用非字符串的键和值了。

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

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

results matching ""

    No results matching ""