2.2 继承误用与组合改造


文档摘要

2.2 继承误用与组合改造 本节摘要:继承是最强的耦合。本节从 JDK 自身的反面教材( 、 )讲到一个业务系统里"基类加一个字段、三个子系统崩"的真实事故,给出 is-a/has-a 判据、组合+委托的改造手法,以及模板方法与接口默认方法的适用边界。 从 JDK 自己踩过的坑说起 Java 标准库里有两个流传最广的继承反面教材。 继承 ,于是它"是一个"动态数组—— / 之外, 、 全部裸露,任何调用方都能从栈中间插一个元素,后进先出的不变量形同虚设。 继承 (键值都被声明为 ),但 没被禁止,一个不走 的调用就能让后续 在类型转换上崩溃。 这两个类的教训是同一条:继承意味着无条件接受父类的全部实现细节,包括你没打算要的那部分。子类与父类的耦合不是"语义上像",而是"实现上焊死"。

2.2 继承误用与组合改造

本节摘要:继承是最强的耦合。本节从 JDK 自身的反面教材(Stack extends VectorProperties extends Hashtable)讲到一个业务系统里"基类加一个字段、三个子系统崩"的真实事故,给出 is-a/has-a 判据、组合+委托的改造手法,以及模板方法与接口默认方法的适用边界。

从 JDK 自己踩过的坑说起

Java 标准库里有两个流传最广的继承反面教材。

java.util.Stack 继承 Vector,于是它"是一个"动态数组——push/pop 之外,get(int)insertElementAt 全部裸露,任何调用方都能从栈中间插一个元素,后进先出的不变量形同虚设。Properties 继承 Hashtable(键值都被声明为 String),但 put(Object,Object) 没被禁止,一个不走 setProperty 的调用就能让后续 getProperty 在类型转换上崩溃。

这两个类的教训是同一条:继承意味着无条件接受父类的全部实现细节,包括你没打算要的那部分。子类与父类的耦合不是"语义上像",而是"实现上焊死"。

业务版事故:一个字段震塌三个系统

某电商的结算链路里有个 BaseRequest,三年间被短信、push、结算三个子系统继承。某次迭代,结算侧需要加一个 settleCurrency 字段,顺手加在 BaseRequest 上。上线当晚:

  • 短信网关的序列化报文多了字段,对端严格校验直接拒收;
  • push 服务的内存缓存按 BaseRequest 全字段算大小,命中率下跌;
  • 结算自身反而没问题。

修复花了两小时,善后(对端灰度、缓存预热)花了三天。复盘会上达成共识:继承让"父类的私有决策"变成"全体现任与未来子类的公共约束",而做出这个决策的人往往不知道有多少子孙。

判据与改造

is-a 判据要看语义而非表面。"充电宝是一个电池"在物理上成立,但若你的 Battery 类暴露了 dischargeToEmpty,让充电宝继承它就是灾难。工程上更实用的判据是变更影响测试:想象给父类加一个方法、改一个语义,你能不能接受所有子类被动继承这个变化?不能,就该组合。

组合改造的手法是"持有 + 委托":

// 误用继承:WordList 把 Vector 的所有危险操作一并继承 class WordList extends Vector<String> { /* ... */ } // 组合:只暴露需要的形状 class WordList { private final List<String> words = new ArrayList<>(); public void add(String w) { words.add(w); } public String last() { return words.get(words.size() - 1); } public int count() { return words.size(); } // 不需要的能力根本不存在 误用无从谈起 }

改造后有个立竿见影的收益:接口面收窄到业务真正需要的形状WordList 没有 get(int),就不存在"按索引越界改词"的 Bug 类别——不是被禁止,而是不存在。

继承与组合的取舍矩阵

继承与组合的取舍矩阵

继承并非一无是处

要公平:继承在框架扩展点上仍是正确工具。HttpServlet 通过 doGet/doPost 留白让子类填充,模板方法模式把"流程骨架固定、步骤开放"的结构表达得非常干净。这类场景的共性是:父类是特意设计为被继承的,JDK/框架作者替你控制了父类的演化纪律(不随便加公有方法、改动有版本化承诺)。

反之,继承一个不是为继承而写的具体类,就是在读它的源码之前就签了全责协议。Effective Java 把这条总结为"要么为继承而设计并写文档,要么禁止继承"——final 类和 String 的不可变设计就是这个原则的正面示范。

Java 8 的接口默认方法提供了中间态:横切能力(如 Comparator 的组合方法)以默认方法下发,实现类选择性覆写,比"为了一个工具方法去继承工具类"干净得多。

⚠️ 常见坑:子类覆写方法时调用被父类构造器间接调用的方法。父类构造器执行时子类字段还没初始化,覆写方法读到的是默认值——"构造期调用可覆写方法"是继承里最阴的时序坑。

💡 关键直觉:继承是白盒复用(我知道你怎么实现),组合是黑盒复用(我只知道你能做什么)。黑盒的合同更稳定,所以默认选组合,继承留给真正的多态层次。

防坑清单

  • 具体类之间不继承;要继承就继承抽象类/接口,且父类有演化纪律
  • 工具能力用组合内持,对外只委托白名单方法
  • 基类放公共字段前做"变更影响测试":列出全部子类与序列化消费方
  • 父类构造器里不调用可覆写方法
  • 跨模块的公共基类评审必须拉上所有子类 owner

本节要点回顾

  • 反面教材Stack/Properties 展示了继承如何暴露不该有的能力
  • 事故机理:父类实现细节变更无差别传导到全部子类
  • 判据:多态替换需求 × 父类稳定性,四象限决策,业务代码默认落在"接口+组合"
  • 正面场景:框架特意设计的扩展点(HttpServlet、模板方法)继承仍是首选
  • 构造时序:父类构造期调用可覆写方法是隐蔽的初始化陷阱

下一节进入内存维度:内部类如何把一个大对象悄悄拖住不放。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U