该用 private final 字段+构造器注入组合对象:当复用功能但非“是一种”关系时,可提前校验依赖、便于测试、避免 new 硬编码和字段暴露;继承 Vector 实现 Stack 是反例,因语义混乱、无法替换底层、易受父类变更影响。

什么时候该用 private final 字段 + 构造器注入组合对象
当你要复用另一个类的功能,但两者之间不是“是一种”的关系(比如 Car 不是 Engine 的一种),就该用组合——而且首选 private final 字段配合构造器注入。
这样做能提前拦截空依赖、避免运行时 NullPointerException,也方便单元测试时传入 MockEngine 或 StubTransmission。
- 别在类里直接写
new Engine():无法替换、无法 mock、生命周期失控 - 别用
public或protected字段暴露组合对象:破坏封装,外部可能篡改状态 - 别提供
setEngine()除非真需要运行时热切换(比如灰度切换支付渠道) - 构造器参数务必用
Objects.requireNonNull()校验:失败早于业务逻辑,不等到start()才崩
为什么继承 Vector 实现 Stack 是个经典反例
JDK 早期的 Stack 继承自 Vector,结果它既暴露了 Vector 全套增删查改接口(add()、remove()、get()),又靠重写部分方法来模拟栈行为——这导致语义混乱、线程不安全、且无法替换底层容器。
正确做法是组合一个 List,只暴露 push() 和 pop(),内部委托调用:
立即学习“Java免费学习笔记(深入)”;
public class Stack<E> {
private final List<E> delegate = new ArrayList<>();
public void push(E item) { delegate.add(item); }
public E pop() { return delegate.remove(delegate.size() - 1); }
}- 继承让
Stack意外获得Vector的同步开销和扩容策略,而实际不需要 - 子类无法绕过父类构造器链,但组合对象可随时换成
LinkedList或带缓存的自定义实现 - 如果父类某天删掉
ensureCapacity()这种 protected 方法,所有继承它的子类都会编译失败
哪些继承场景其实很危险,但开发者常踩坑
不是所有“看起来像父子关系”的地方都适合继承。尤其当父类没明确声明“可被继承”时,强行 extends 很容易引发隐性崩溃。
-
HashSet重写了add()去操作内部HashMap,你再继承它并重写add(),很可能漏掉map.put(),导致size()和真实元素数不一致 -
String、LocalDateTime被设为final,不是为了“防扩展”,而是因为它们的状态契约太脆弱,子类根本无法维持不变量 - 父类 JavaDoc 里没写 “This class is designed for inheritance”,却有大量
private实现细节或final方法——这时继承基本等于埋雷 - 子类重写父类方法后抛出
IllegalArgumentException,但父类文档没说明这个约束,调用方按原契约使用就出错
组合不是拒绝继承,而是让继承回归语义本质
Java 里依然需要继承:比如 AbstractList 提供骨架实现,HttpServlet 定义统一回调入口,Spring 的 ApplicationRunner 强制框架约定。但这些继承必须窄而深——只解决“类型契约”,不掺杂“能力装配”。
一个类可以既有继承又有组合,关键看分工是否清晰:
- 继承负责“我是谁”(
class JsonLogger extends Logger表明它是Logger的一种) - 组合负责“我用什么”(
JsonLogger内部持有private final ObjectMapper来序列化) - 如果发现子类要覆盖父类大部分方法,或者总在补救父类设计缺陷(比如加
if (isLegacyMode)),那大概率本该用组合
最常被忽略的一点是:组合的灵活性不来自“多写几行代码”,而来自它把变化点显式地拎出来——哪个对象可换、何时换、怎么换,全都落在构造器参数或 setter 上,而不是藏在继承树深处。










