《Effective Java》笔记16:复合优先于继承

  1. 问题
  2. 解决

继承是实现代码重用的有力手段,但它并非是最好的手段。

在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下。对普通的具体类进行跨越包边界的继承,则是非常危险的,因为你一旦发布包之后,你就得要遵循你的接口,而不能轻易的去修改它而影响客户端已有的应用。

与方法调用不同的是,继承打破了封装性。换句话说,子类依赖于其超类中特定的功能的实现细节。超类的实现有可能会随着发行版本的不同而有所变化,如果真的发生了变化,子类可能会遭到破坏,即使它的代码完全没有改变。

这里的复合,可以理解为就是 Decorator 设计模式(装饰者模式

问题

下面程序要使用 HashSet 功能,用来记录添加了多少个元素,直接采用了继承:

public class InstrumentedHashSet<E> extends HashSet<E> {
    /*
     * 用来记录添加了多少个元素,与 HashSet 的 size 是不一样的
     * HashSet 的 size 会受到删除的影响,而这个只记录添加的元素个数
     */
    private int addCount = 0;
    public InstrumentedHashSet() {}

    @Override
    //因为要记录我已放入的元素,所以重写了父类的方法
    public boolean add(E e) {
        addCount++;
        return super.add(e);//最终还是调用 HashSet 的 add 方法将元素存入
    }

    /*
     * super.addAll 是以调用 HashSet 的 add 方法来实现的,而 add 方法又被子类
     * 重写了,所以该类的 add 方法也会被再次调用,即实质上调用 addAll
     * 方法的时候,它也会去调用一下 add 方法,这在我们不调试的情况下
     * 是很难发现的,这就是继承所带来的后果:因为我们依赖了父类的实现的细节。
     */
    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

我们只去掉被重写的 addAll 方法中的“addCount += c.size();”,就可以“修正”这个子类,但我们不能否认这样的实事:HashSet 的 addAll 方法是在它的 add 方法上实现的,这种“自用性(self-use)”是实现的细节,它不是承诺,所以不能保证在 Java 平台的所有实现中都保持不变,即不能保证随着发行版本的不同而不发生变化,这样得到的 InstrumentedHashSet 类将是非常脆弱的。

这里稍微好一点的做法是,在 InstrumentedHashSet 中重写 addAll 方法来遍历指定的集合,为每个元素调用一次 add 方法,这样做可以保证得到正确的结果 ,不管 HashSet 的 addAll 方法是否是以调用 add 方法来实现的,因为 HashSet 的 addAll 实现将不会再被调用。相当于重新实现了父类的方法,这种方法可能实现起来很麻烦。

导致子类脆弱的一个相关的原因就是,它们的超类在后续的版本中可能添加新的方法。假设我们的程序的有这样一个要求:所有被插入到某个集合中的元素都必须满足某个条件(比如上面在放入之前 addCount 会记录一下)。这样做是可以确保一点:对集合进行子类化,并覆盖所有能够添加元素的方法,以便确保在加入每个元素之前它是满足这个先决条件的。如果在后续的版本中,超类中没有增加能插入元素的新方法,这种做法是安全的。然而,一旦超类增加了这样的新方法,则很可能仅仅由于调用了这个未被覆盖的新的方法,而将“非法的”元素添加到子类的实例中。这不是纯粹的理念问题,在把 Hashtable 和 Vector 加入到集合框架中时,就修正了这几个类性质的安全漏洞。

上面的问题都是来源于覆盖动作。如果在继承一个类的时候,仅仅是添加新的方法,而没有覆盖现有的父类中的方法,你可能会认为是安全的,但是不然,比如你现在已继承了某个类,并扩展了父类,并添加了父类中没有方法,但是有可能将来父类也会添加同样签名的方法,但重写的条件不满足时,此时你设计的子类将不能编译,或者即使满足重写条件,这样又会有问题(如父类私有域状态的维护工作)。这样的问题不是不存在的,因为当你在编写子类方法时候,你肯定是不会知道将来父类会增加这样的名称的方法。

解决

幸运的是,有一种办法可以避免前面提到的所有问题,不用继承现有类,而是在新的类中增加一个私有域,它引用现有类的一个实例。这种设计被称做为“复合”,因为现有类变成了新的类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为“转发”,新类中的方法被称为转发方法。这样得到的类将会非常稳固,它不依赖于现有类的实现细节,即使现有的类添加了新的方法,也不会影响新的类。下面我们使用“复合”来修正前面的问题,注意这个实现分为两部分:类本身和可重用转发类,包含了所有的转发方法,没有其他方法。

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;//组合
    public ForwardingSet(Set<E> s) { this.s = s; }

    //下面全是转发方法(Set中的方法)
    public void clear()               { s.clear();            }
    public boolean contains(Object o) { return s.contains(o); }
    // ...

    //下面全是重写Object中的方法

    @Override public boolean equals(Object o) { return s.equals(o);  }
    @Override public int hashCode() { return s.hashCode(); }
    @Override public String toString() { return s.toString(); }
}

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 static void main(String[] args) {
        // 包装
        InstrumentedSet<String> s = new InstrumentedSet<String>(new HashSet<String>());

        s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));
        System.out.println(s.getAddCount());
    }
}

Set 接口的存在使得 InstrumentedSet 类的设计成 Set 集合类成为可能,因为 Set 接口保存了 HashSet 类的功能特性,除了获得健壮性之外,这种设计也带来了格外灵活性。InstrumentedSet 类实现了 Set 接口,并且拥有单个构造器,它的参数也是 Set 类型,从本质上讲,这个类把一个 Set 转变成了另一个 Set,同时增加了计数器功能。

因为每一个 InstrumentedSet 实例都把另一个 Set 实例包装起来了,所以 InstrumentedSet 类被称做为包装类,这也正是 Decorator 装饰模式。InstrumentedSet 类对一个集合进行了装饰,为它增加了计数特性。有时,复合和转发的结合也被错误地称为“委托”,从技术的角度而言,这不是委托,除非包装对象(InstrumentedSet)把自身传递给被包装的对象(HashSet)。

包装类几乎没有缺点,但需要注意的一点是,包装类不适合用在回调框架中;在回调框架中,对象把自身的引用传递给其他的对象,所以它传递一个指向自身的引用(this),回调时避开了外面包装对象,这被称为 SELF 问题。

只有当子类真正是超类的子类型时,才适合用继承。换句话说,对于两个类 A 和 B,只有当两者之间确实存在“is-a”关系的时候,类B才应该扩展类 A。如果你打算让类 B 扩展类 A,就应该问问自己了:每个 B 确实也是 A 吗?如果不能确定,通常情况下,B 应该包含 A 的一个私有实例,并且暴露一个较小的、比较简单的 API:A 本质上不是 B 的一部分,只是它的实现细节而已。

在 Java 平台类库中,有许多明显违反这条原则的地方,例如,栈并不是向量,所以 Stack 不能继承 Vecotr;属性列表也不是散列,所以 Properties 不能继承 Hashtable,在这种情况下,复合模式才是恰当的。

如果在适合于使用组合的地方使用了继承,则会不必有地暴露实现细节(如暴露不必要的接口导致外界调用这些接口来非法改变实例的状态)。这样得到的 API 会把你限制在原始的实现上,永远限定了类的性能,更为严重的是,由于暴露了内部的细节或不必要的接口,客户端就有可能直接访问这些内部细节,从而破坏实现的内部状态。

继承机制会把超 API 中的所有缺陷传播到子类中,而复合则允许设计新的 API 来隐藏这些缺陷。

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


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记16:复合优先于继承

文章字数:2.5k

本文作者:Bin

发布时间:2018-10-10, 21:06:22

最后更新:2019-08-06, 00:43:08

原始链接:http://coolview.github.io/2018/10/10/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B016%EF%BC%9A%E5%A4%8D%E5%90%88%E4%BC%98%E5%85%88%E4%BA%8E%E7%BB%A7%E6%89%BF/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录