2026-11-13 组合优于继承:一个计数器数错了的例子

「组合优于继承」这句话我背了很久,但一直没搞懂为什么——继承明明更省事,少写一堆转发代码。直到我自己写了个计数器,一次 addAll 放进去 3 个元素,它告诉我加了 6 个。

一、需求很简单

想要一个 Set,除了能装东西,还能告诉我「一共加过几个元素」。用继承来实现,十行就够:

class InheritedSet<E> extends HashSet<E> {
    int addCount = 0;

    @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);
    }
}

跑一下:

一次 addAll 放进去 3 个元素
集合里实际有 3 个
计数器说加了 6 个

集合里是 3 个,计数器说 6 个。数翻倍了。

二、多的那 3 次从哪来

看调用链:

InheritedSet.addAll(c)
  └─ addCount += 3        ← 我自己加的
  └─ super.addAll(c)      ← 交给 HashSet
       └─ ... 内部对每个元素调用 add()
            └─ 实际调用的是 InheritedSet.add()   ← 因为我覆写了它
                 └─ addCount++                  ← 又被加了一次

关键在于最后一步:super.addAll 内部调用的是 this.add,而 this 是我的子类。

Java 的方法调用是动态分派的。父类代码里写 add(...),运行时看的是对象的实际类型。所以我覆写的 add 被父类的内部实现调用了——这不是 bug,是继承的正常行为。

问题在于:我在覆写 addAll 的时候,不知道 HashSet.addAll 内部会调 add。 这个信息不在 API 文档的承诺里,是它的实现方式。

三、那我干脆不覆写 addAll 呢

试试只覆写 add:

class LazyInheritedSet<E> extends HashSet<E> {
    int addCount = 0;
    @Override public boolean add(E e) {
        addCount++;
        return super.add(e);
    }
    // 不覆写 addAll
}
计数器说加了 3 个,集合里 3 个

对了。

但这个"对"是怎么来的? 是因为 HashSet.addAll 恰好内部调用了 add,而那一次调用正好经过我的覆写方法。如果哪天 JDK 换成批量拷贝的实现,不再逐个调 add,我的计数器立刻变成 0。

也就是说:我的正确性,建立在一个我没法控制、也没被承诺的实现细节上。

这就是继承的脆弱之处——不是"用继承一定错",而是"用继承的正确性,可能挂在父类的内部实现上"。

四、组合版本

改成内部持有一个 Set,不继承:

class ComposedSet<E> {
    private final HashSet<E> inner = new HashSet<>();
    int addCount = 0;

    boolean add(E e) {
        addCount++;
        return inner.add(e);
    }

    boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return inner.addAll(c);
    }
}
计数器说加了 3 个,集合里 3 个

也对,而且这次不靠运气。

原因很直接:inner.addAll(c) 内部调的是 inner.add——inner 是一个普通的 HashSet 实例,它调自己的 add,永远不会绕回到 ComposedSet.add 上来。外部类和内部对象的调用链是断开的。

五、三种写法摆在一起

写法                     实际元素    计数
A 继承+全覆写               3         6      ← 错
B 继承+只覆写add            3         3      ← 对,但靠运气
C 组合                     3         3      ← 对,且稳定

A 和 B 的差别只有三行代码,结果从"错"变成"对"——但 B 的对是脆的。

C 多写了一些转发方法(真实场景里要实现 Set 接口的话得写十几个),换来的是一件事:我的行为只取决于我自己的代码。

六、那什么时候该用继承

不是说继承不能用。判断标准我觉得就一条:

「is-a」成立,而且父类的设计是为继承准备的(有明确的钩子方法、文档写明了哪些方法会互相调用),那可以用。

HashSet 不是为继承设计的——它的文档没承诺 addAll 会不会调 add。在没承诺的地方继承,就是在赌。

反过来,AbstractList、HttpServlet 这类就是明确为继承准备的,覆写钩子方法是被支持的用法。

七、结论

「组合优于继承」真正的意思不是"继承有害",而是:

  • 继承让你依赖父类的实现,而实现可以变
  • 组合让你只依赖对象的接口,而接口是承诺

那个计数器数错 3 次,就是"依赖了没被承诺的东西"最直白的代价。

附:完整代码

// A. 继承 + 同时覆写 add 和 addAll —— 计数翻倍
static class InheritedSet<E> extends HashSet<E> {
    int addCount = 0;
    @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);      // 内部又调了 this.add
    }
}

// C. 组合 —— 内部持有,调用链断开
static class ComposedSet<E> {
    private final HashSet<E> inner = new HashSet<>();
    int addCount = 0;

    boolean add(E e) {
        addCount++;
        return inner.add(e);
    }
    boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return inner.addAll(c);      // 调的是 inner.add,绕不回来
    }
}
posted @ 2026-11-13 16:05  呱哒  阅读(10)  评论(0)    收藏  举报