美文网首页
Effective Java 3rd 条目18 组合优于继承

Effective Java 3rd 条目18 组合优于继承

作者: tigershin | 来源:发表于2018-01-27 18:44 被阅读6次

    继承是取得代码复用的一种强大方式,但是对于这项工作它不总是最好的工具。如果使用不恰当,那么这将导致脆落的软件。在一个包里面使用继承是安全的,包里面的子类和超类实现是在同一个程序员的控制之下。当特意为扩展而设计和文档化的类(条目19)时,使用继承也是安全的。跨包界线继承普通具体类,无论如何是危险的。提醒一下,这本书使用“继承”意思是实现继承(implementation inheritance)(当一个类扩展另外一个)。这个条目里面讨论的问题不适用于接口继承(interface inheritance)(当一个类实现了一个接口或者当一个接口扩展了另外一个)。

    不像方法调用,继承破坏了封装性[Snyder86]。换句话说,一个子类为了它的本身功能,依赖于它超类的实现细节。超类实现可能随着版本发布改变而改变,而且如果确实是如此,子类可能遭到破坏,即使没有碰触它的代码。因此,子类必须和它的超类一起演化,除非超类的作者特意为了扩展超类而设计和文档化它。

    为了使得这个具体,让我们假设我们有一个使用HashSet的程序。为了调优我们的程序,我们需要查询HashSet关于自从创建它以来添加了多少个元素(不要与它当前的大小相混淆了,当移除一个元素时当前大小减少)。为了提供这个功能,我们编写了一个HashSet变体,它保存了元素尝试插入的数目计数,而且对这个计数有一个访问方法。HashSet类包含了两个方法add和addAll,可以添加元素。所以我们覆写了这两个方法:

    // 已破坏 - 继承的不恰当使用!
    public class InstrumentedHashSet<E> extends HashSet<E> {
        // 元素尝试插入的数目
        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(它在Java9中添加的)创建了一个列表;如果你使用一个早期的版本,那么使用Arrays.asList代替:

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

    我们本来期待在这个时间点getAddCount方法返回三,但是它返回了六。哪里出了问题呢?在内部,HashSet的addAll方法是在它的add方法上实现的,虽然HashSet没有文档化这个实现细节,但是这是相当合理。InstrumentedHashSet的addAll方法添加三个到addCount,然后使用super.addAll调用HashSet的addAll实现。这转而调用InstrumentedHashSet里覆写的add方法,每次为一个元素。这三个调用的每一个再次添加了一到addCount,总共增加了六:用addAll方法添加的每个元素是双倍计数的。

    我们可以通过消除addAll方法的子类覆写而“修正”子类。虽然最终的类会行得通,但是这让它的正常功能依赖于这样的事实:HashSet的addAll方法是实现在它的add方法上的。这个“自用”是实现细节,不会保证在Java平台的所有实现中有效,而且受制于一个发布到另一个发布的改变。所以,最终的InstrumentedHashSet类将会脆弱的。

    覆写addAll方法对给定的集合迭代,为每个元素每次调用add方法,这将稍微好一点。这将保证正确的结果,不管
    HashSet的addAll是否是实现在它的add方法之上,因为不再调用HashSet的addAll实现。然而,这个技巧没有解决我们的所有问题。这相当于从新实现超类方法,这些方法可能会或者可能不会自用,这是困难的、费事的、容易出错的而且可能降低性能。此外,它不总是可能的,因为一些方法在没有访问私有域(子类不可获取)时不可能实现。

    子类中脆弱的一个相关原因是,它们的超类可以在后续发布中获得新方法。假设一个程序让它的安全取决于这样一个事实:插入到某个集合中的所有元素满足某个断言(predicate)。这可以通过以下来保证:子类化集合和覆写可以添加元素的每个方法,确保在添加元素之前满足这个断言。这正常运行,直到在后续发布中可以插入元素的一个新方法添加到超类中。一旦这个发生,通过调用新方法(在超类中没有覆写这个新方法)添加一个“非法”元素,这变得可能了。这不纯粹是一个理论问题。当改造Hashtable和Vector参与到Collections框架时,许多这个性质的安全漏洞不得不解决。

    这两个问题都的根源在于覆写方法。你可能认为扩展一个类是安全的,如果你仅仅添加新方法而且避免覆写已经存在的方法。虽然这种形式的扩展是更加安全,但是它不是没有风险的。如果超类在后续发布中获得一个新方法,而且你运气不好,给子类一个同样签名而不同返回类型的方法,那么子类不再可以编译 [JLS, 8.4.8.3]。如果你已经给了子类与新超类方法一样签名和返回类型的方法,那么现在你覆写了它。此外,你的方法满足新超类方法的协定,这是值得怀疑的,因为当你编写子类方法的时候,这个协定还没有编写。

    幸好,有一种方式避免上面描述的所有问题。不是扩展一个已经存在的类,而是给你的新类一个私有域,它引用已经存在类的实例。这个设计叫组合(composition),因为已经存在的类成为新类的组件。新类中的每个实例方法调用已经存在类的包含实例的相应方法。这被称为转发(forwarding),而且新类的方法被称为转发方法(forwarding method)。最终的类将是牢固可靠的,不依赖于已经存在类的实现细节。即使添加新方法到已经存在类,也不会对新类有影响。为了使得这个具体,下面是InstrumentedHashSet的替代,它使用组合和转发方法。注意,这个实现被拆成两部分,类它自己和一个复用的转发类,转发类包含了所有转发方法而没有其他:

    // 包装类 - 使用组合代替继承 
    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; 
        }
    }
    
    
    // 可复用的转发方法 
    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转换到另外一个,添加了插桩(instrumentation)功能。继承的方法仅仅对单一具体类起作用而且对于超类中每个支持的构造子需要一个单独构造子,不像基于继承的方法,包装类可以使用于插桩任何Set实现,而且将和任何以前存在的构造子协同工作:

    Set<Instant> times = new InstrumentedSet<>(new TreeSet<>(cmp)); 
    Set<E> s = new InstrumentedSet<>(new HashSet<>(INIT_CAPACITY));
    

    InstrumentedSet类甚至可以暂时使用于插桩一个集实例,这个集实例已经没有插桩地在使用:

    static void walk(Set<Dog> dogs) { 
        InstrumentedSet<Dog> iDogs = new InstrumentedSet<>(dogs); 
        ... // 这个方法使用iDogs而不是dogs 
    }
    

    InstrumentedSet类被称为包装(wrapper)类,因为每个InstrumentedSet类包含(“包装”)了另外一个Set实例。这也叫做装饰(Decorator)模式[Gamma95],因为InstrumentedSet类通过添加插桩而“装饰”一个集。组合和转发的联合有时被粗略地认为是代理(delegation)。技术上它不是代理,除非包装对象把它自己传递给被包装的对象[Lieber-man86; Gamma95]。

    包装类的缺点很少。一个警告是,包装类是不适合在回调框架(callback framework)中使用,这个框架中为了后续的调用(“回调”),对象把自引用传递到其他对象。因为包装对象不知道它的包装,它把一个引用传递到它自己(this)而且回调避开了包装。这叫做SELF问题[Lieberman86]。一些人担心调用转发方法的性能影响,或者包装对象的对象占用影响。在实践中两件结果都没有显著的影响。编写转发方法是枯燥无味的,但是你为每个接口不得不编写仅此一次的可复用转发类,而且转发类可能为你提供。比如,Guava为所有集合接口提供了转发类[Guava]。

    仅仅在子类真正是超类的子类型的情形下,继承是适合的。换句话说,如果两个类之间存在“is-a”关系,那么类B应该扩展类A。如果你倾向于让类B扩展类A,问问自己这个问题:每个B真正是一个A吗?如果你不能毫不怀疑地对这个问题说是,B不要扩展A。如果回答是否,那么通常情形是,B应该包含一个A私有实例,而且暴露一个不同的API:A不是B的基本部分,仅仅是它实现的一个细节。

    在java平台库中有许多明显违反这个原则。比如,栈不是一个vector,所以Stack不应该扩展Vector。相似地,属性列表不是一个哈希表,所以Properties不应该扩展Hashtable。这两个例子中,组合应该是更加可取的。

    如果你在组合适合的地方使用继承,那么你不必要地暴露了实现细节。最终的API把你绑到原来的实现,永远限制了你的类的性能。更为糟糕的是,通过暴露内部结构,你使得客户端直接获取它们。起码,这导致了令人困惑的语义。比如,如果p引用Properties实例,那么p.getProperty(key)可能产生与p.get(key)不同的结果:前者方法考虑了默认,而继承于HashTable的后者方法并没有。更为严重的是,客户端可能通过直接修改超类而破坏子类的不变性。Properties情况下,设计者意图为,只允许字符串作为键和值,但是直接访问底层的Hashtable,违反了这个不变性。一旦违反了,使用Properties API的其他部分(load和store),变得不再可能了。到发现这个问题的时候,更正它就太迟了,因为客户端已经依赖于非字符串的键和值的使用。

    在决定使用继承代替组合之前,你应该问你自己最后一类问题。你思考扩展的这个类在它的API上有没有缺陷?如果是这样,对于传播这些缺陷到你的类的API,你感到可接受吗?继承传播超类API的任何缺陷,而组合让你设计一个隐藏这些缺陷的新API。

    总之,继承是强大的,但是它是有问题的,因为它违反了封装性。仅仅当子类和超类之间存在一个真正子类型关系时,这是合适的。即使那样,如果子类是来自不同于超类的包,而且超类不是设计为继承,那么继承可能导致脆弱性。为了避免这个脆弱性,使用组合和转发而不是继承,特别是为实现一个包装类的一个恰当接口存在时。不仅包装类比子类更加强健,而且它们也是更加功能强大的。

    相关文章

      网友评论

          本文标题:Effective Java 3rd 条目18 组合优于继承

          本文链接:https://www.haomeiwen.com/subject/slqoaxtx.html