Effective Java(3rd)-Item18 组合优于继承

  继承是实现代码复用的强大的方式,但是它在工作上不总是最适合的工具。使用不当,会导致软件脆弱。在同一个包中使用继承是安全的,其中子类和超类的实现是在相同的程序员的控制下。在专门为扩展而设计和记录的类扩展时,使用继承也是安全的 (item19) 。然而,跨越包边界的普通具体类的继承是危险的。在此提醒,本书使用单词“inheritance”表示实现继承(一个类继承另一个)。这个条目中讨论的问题不适用于接口继承(当一个类继承一个接口或一个接口继承另一个接口)。
  与方法调用不同,继承违反了封装[Snyder86]。换句话说,子类依赖于它的超类的实现细节来实现正确功能。超类的实现可能在不同版本之间发生变化,如果确实如此,子类就有可能被破坏,即使它的代码未被触及。因此,子类必须随着其超类一起演变,除非超类的作者为了扩展而专门设计和记录它。
  为了使之具体化,让我们假定我们有一个程序使用HashSet。为了调整我们的程序的性能,我们需要查询HashSet用于查询自其创建时有多少元素被添加(不要混淆认为是当前大小,这当元素被删除时会减少)。为了提供这个功能,我们编写了一个HashSet变种,它保持了元素插入的次数的计数,并为这个计数导出一个访问器。HashSet类包含了两个添加元素的方法add和addAll,所以我们重写这两个方法:

// 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方法添加了三个元素。顺便提一下,注意到我们使用Java9中的静态工厂方法List.of创建一个列表;如你你使用更早的版本,使用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方法,每元素加1。这三次调用中的每次调用都在addCount中加了1,总共就增加了6:使用addAll方法添加的每个元素都被重复计算了。
  我们可以通过去掉addAll方法的覆写来“修复”这个子类。虽然生成的类可以正常工作,但是它还是依赖于HashSet的addAll方法在其add方法之上实现的正确功能。这种“自我使用”是一个实现细节,并不能保证在Java平台的所有实现中都能保留,并且随着版本的不同而发生变化。因此,生成的InstrumentedHashSet类是脆弱的。
  覆写addAll方法来迭代指定的集合,为每个元素调用一次add方法会稍微好一点。无论HashSet的addAll方法是否基于它的add方法,这将会保证正确的结果,因为HashSet的addAll方法的实现将不再被调用。然而,这种技术并没有解决我们的所有的问题。它相当于重新实现了超类的可能导致或可能不导致自我使用的方法,这是困难的,耗时的,容易出错的,可能降低性能。此外,它并不总是可行的,因为一些方法在没有访问子类私有字段的情况下是不能实现的。
  子类脆弱性的一个相关原因是它们的超类可以在后续版本中获取新方法。假设程序依赖于其安全性,基于所有元素插入一些集合满足某些断言。这可以通过继承集合覆写每个能添加元素的方法来保证,以保证在添加元素之前满足断言。这可以正常工作直到在后续版本中超类有新的方法能将元素插入。一旦发生这种情况,仅通过调用新方法就可以添加“非法”元素,而子类不会覆写该方法。这不是一个纯理论问题。当Hashtable和Vector被改造以参与集合框架时,一些这种性质的安全漏洞就必须要被修复。
  这些问题都出自覆写方法。你可能会认为如果你仅仅添加新方法并避免覆写已存在方法,就能安全地扩展类。虽然这种扩展更加安全,但是并非没有风险。如果超类在后续版本中需要一个新方法,并且你不幸给了子类一个相同的签名和不同返回类型的方法,那么你的子类将不再编译[JLS, 8.4.8.3]。如果你给了子类和超类新方法相同前面相同返回类型的方法,那么你就要覆写它,就会遇到前面描述的问题。此外,你的方法将满足新的超类方法的契约是值得怀疑的,因为在编写子类方法时并未编写该契约。
  幸运的是,有一种方法可以避免上述的所有问题。不是扩展现有类,而是为新类提供引用现有类实例的一个私有字段。这种设计称为组合,因为现有类成为了新类的一个组件。新类中的每个实例方法都在现有类的包含实例上调用相应的方法并返回结果。这称为转发,新类中的方法被称为转发方法。结果类将是坚若磐石,不依赖于现有类的实现细节。即使在现有类中添加新方法,也不会对新类产生影响。为了具体化,这里是使用组合和转发方法的InstrumentedHashSet的替代。请注意,实现分为两部分,类本身和可重用的转发类,其中包含所有的转发方法,不包含其他任何:

// 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();
    }
}

  InstrumentedSet类的设计由Set接口的存在启用,该接口捕获了HashSet类的功能。除了坚固外,这种设计非常灵活。InstrumentedSet类实现了Set接口并有一个单独的构造方法,其参数就是Set类型。本质上,这个类将一个Set转化为另一个,添加了检测功能。与基于继承的方法不同,后者仅适用于单个具体类,需要为超类中每个受支持的构造方法提供单独的构造方法,包装类可以用于检测任何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);
        ... // Within this method use iDogs instead of dogs
    }

  InstrumentedSet类亦可称为包装类,因为每个InstrumentedSet实例包含(“包装”)另一个Set实例。这也被称为装饰器模式,因为InstrumentedSet类通过添加检测“装饰”了一个集合。有时候,组合和转发的组合被宽泛地被称为委托。从技术上将,这不是委托,除非包装器对象将自身传递给包装对象[Lieberman86; Gamma95]。
  
  包装类的缺点不多。提醒的是,包装类不适合在回调框架中使用,其中对象将自引用传递给其他对象以进行后续调用(“回调”)。因为包装对象不知道它的包装器,他会传递对他自己的引用(this),并且回调会逃避包装器。这被称为SELF问题[Lieberman86]。有些人担心转发方法调用对性能的影响或包装器对象的内存占用影响。结果在实践中都没有太大影响。编写转发方法很繁琐,但是你必须为每个接口编写一次可重用转发类,并且可以提供转发类,例如,Guava为所有集合接口提供转发类[Guava]。
  只有在子类确实是超类的子类型的情况下,继承才是合适的。换句话说,类B应该继承A有且仅当在这两个类中有“is-a”关系存在。如果你想让B扩展A,问问你自己一个问题:每个B真的都是A吗?如果你不能如实回答yes,B就不该继承A。如果答案是no,通常情况下B应该包含A的一个私有实例并暴露不同的API:A不是B的必要部分,仅仅只是它实现的细节。
  Java平台库有许多明显违反规定的行为。例如,堆栈不是向量,因此Stack不该扩展Vector。同样,属性列表不是哈希表,所以属性不能扩展Hashtable,在这两种情况下,组合是优选的。
  如果在适合组合的地方使用继承,你将会不必要地暴露实现细节。生成的API将使你和原始实现联系绑定,永远限制你的类的性能。更严重的是,通过暴露内部,你可以让客户端直接访问。至少,它可能会导致语义混乱。例如,如果p引用Properties实例,则p.getProperty(key)可能会产生与p.get(key)不同的结果:前一种方法将默认值考虑在内,后一种方法从hashtable继承。最严重的是,客户端可能会通过直接修改超类来破坏子类的不变量。在Properties的情况下,设计者希望只允许字符串作为键和值,但是对底层hashtable的直接访问允许不违反这个不变量。一旦违反,就不能再使用Properties API的其他部分(加载和存储)。当发现这个问题时,由于客户端依赖于使用非字符串键和值,因为纠正他为时已晚。
  在决定使用继承代替组合之前,你应该问自己最后一组问题,你可以考虑扩展的类在其API中是否存在任何缺陷?如果是这样,你是否原因将这些缺陷传播到你类的API中?继承传播超类API中任何缺陷,而组合允许你设计隐藏这些一些的API。
  总而言之,继承是强大的,但是
它也是有问题的,因为它违反了封装。只有当子类和超类之间存在真正的关系时才适用。即使这样,如果子类与超类位于不同的包中,并且超类不是为了继承而设计的,那么继承可能会导致脆弱。为了避免这些脆弱性,请使用组合和转发而不是继承,尤其是存在实现包装类的适当接口的情况下。超类类不仅比子类更健壮,而且功能更强大。
本文写于2019.4.5,历时4天

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,142评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,298评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,068评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,081评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,099评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,071评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,990评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,832评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,274评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,488评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,649评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,378评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,979评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,625评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,643评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,545评论 2 352

推荐阅读更多精彩内容