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,绕不回来
}
}
浙公网安备 33010602011771号