文档概述
背景
继承体系中常见一类演化问题:多个子类可以复用父类约 95% 的流程,只有少量业务行为不同。为了降低当次需求的改动成本,差异通常会被写回父类,例如增加业务标志位、条件分支,或新增只服务于某个子类的 hook。随着子类增多,父类逐渐成为所有业务特例的汇合点,公共路径的修改和回归成本持续上升。
问题不在于差异代码占 5% 还是 50%,也不在于是否已经“抽成方法”,而在于:谁拥有差异的决策权。 如果父类仍负责识别业务类型并选择分支,差异就没有离开公共层。
目标与范围
本文以“构造请求并签名”为统一示例,说明以下问题:
- 如何识别子类特例已经渗透到父类;
- 如何判断一个 hook 是否仍是中立扩展点;
- 如何在 Template Method、Strategy 和组合之间做选择;
- 如何迁移已经被业务分支污染的父类。
示例代码为简化后的 Java 片段,用于说明依赖关系和职责边界,不对应具体仓库实现,也不展开网络协议、加密算法或依赖注入框架的细节。
**核心结论:**父类应表达抽象边界内共同成立的契约和稳定骨架,变化步骤可以由子类实现,但父类不应识别某一个分支的专属知识。跨业务父类一旦出现具体子类名称、专属标志位、类型判断或
doXxxExtra()一类业务化 hook,应将差异下沉到业务类,或横向抽取为策略和可组合组件。
问题定义:子类特例渗透父类
本文将“子类特例渗透父类”定义为:原本只属于某个子类或业务场景的知识,反向进入父类的字段、方法签名或控制流。 这会让父类从“定义公共契约”转变为“识别并照顾具体业务”。
本文中的“公共层”特指跨多个业务场景复用的抽象,例如同时服务支付、登录和分享的 BaseRequest;“业务边界”指拥有某项变化及其规则的功能模块;“装配边界”指创建对象并选择具体实现的位置,例如入口层的构造代码、工厂或依赖注入模块。业务名称并非一律不能出现在父类中:如果父类本身就是仅面向支付域的 BasePaymentRequest,支付术语属于其抽象边界。真正的问题是公共父类开始了解只对其中一个分支成立的知识。
常见信号如下:
| 信号 | 示例 | 结构含义 |
|---|---|---|
| 父类出现业务名称 | needPaySign、doPayExtra() | 公共层开始理解具体业务 |
| 父类按类型分派 | instanceof PayRequest、业务 switch | 新增子类需要修改父类 |
| hook 只对少数子类有意义 | 其他子类继承默认空实现或被迫空实现 | 父类契约包含无关能力 |
| 子类必须绕开父类分支 | 覆盖方法仅为阻止父类执行某段逻辑 | 继承关系不再提供稳定替换 |
| 新需求持续修改公共路径 | 每增加一种业务都要改 BaseXxx | 变化方向与依赖边界不一致 |
这与 Fragile Base Class Problem 相关,但不是同一个问题。Mikhajlov 与 Sekerinski 在 ECOOP 1998 的 A Study of The Fragile Base Class Problem 中讨论的是:子类依赖父类未声明的内部细节,父类的局部改动可能破坏子类。Fowler 在 Protected Data 中进一步指出,对外部子类而言,protected 成员实际上也是已发布接口。
本文关注相反方向:子类特例回流到父类,导致父类开始依赖具体业务知识。 两类问题可能同时出现在同一棵继承树中,但诊断依据不同。
失效机制:从中立 Hook 到业务分支
中立 Hook 的判定条件
Template Method 的基本结构是:父类固定算法骨架,将部分步骤延迟到子类实现。工程上还需要增加一项约束:hook 的语义、名称和参数必须对所有合法子类成立,父类不能通过业务名称或类型判断选择某个子类的专属流程。
下面的 calcSign() 是中立扩展点:
class BaseRequest {
final void send() {
Map<String, String> params = buildParams();
params.put("sign", calcSign(params));
http.post(url(), params);
}
protected String calcSign(Map<String, String> params) {
return md5(serialize(params));
}
}
class PayRequest extends BaseRequest {
@Override
protected String calcSign(Map<String, String> params) {
return md5(serialize(params) + payKey);
}
}calcSign() 只表达“请求需要签名”,不包含支付、登录或优惠券等业务概念。增加新的请求类型时,只需覆盖同一个稳定步骤,不需要修改 BaseRequest.send()。
业务化 Hook 的失效方式
下面的代码虽然也使用了方法抽取,但差异决策仍在父类:
class BaseRequest {
boolean needPaySign; // 业务标志位进入父类
final void send() {
Map<String, String> params = buildParams();
if (needPaySign) { // 父类识别支付场景
params.put("sign", paySign(params));
doPayExtra(params); // hook 只对支付有意义
} else {
params.put("sign", md5(serialize(params)));
}
http.post(url(), params);
}
protected void doPayExtra(Map<String, String> params) {
// 非支付子类继承了无意义的扩展点
}
}needPaySign 和 doPayExtra() 的问题不是代码量,而是扩展点失去中立性。登录、分享和优惠券请求都被迫继承与支付有关的契约;增加下一个特例时,团队也更容易继续修改父类。
图 1:左侧父类只定义中立的 calcSign();右侧父类通过业务标志位和专属 hook 持有差异决策。
满足以下条件时,hook 通常可以保留在父类中:
- 方法名称和参数不包含具体子类或业务名称;
- 所有合法子类都能用同一种语义解释该步骤;
- 新增子类不需要修改父类控制流;
- 子类无需通过无意义的空实现或反向覆盖来绕开父类行为。
默认空实现本身不是错误。如果“可选步骤”对所有子类都有一致含义,只是部分子类无需执行动作,no-op 可以是合法默认值。若该能力只对一组子类成立,应优先考虑更窄的中间抽象、能力接口或装饰器,而不是让整棵继承树暴露无关 hook。
渗透的典型演化路径
父类通常不是一次性变得复杂,而是在连续的局部最优修改中逐步失去边界。
图 2:从中立扩展点到业务特例汇合点的典型演化过程。
| 阶段 | 当次改动 | 父类新增内容 | 长期影响 |
|---|---|---|---|
| 1 | 抽取中立的 calcSign() | 稳定扩展点 | 所有子类共享同一语义 |
| 2 | 支付签名不一致,增加 needPaySign | 公共路径中的业务分支 | 父类开始识别支付场景 |
| 3 | 支付还需补充字段,增加 doPayExtra() | 单一业务专属 hook | 其他子类继承无关契约 |
| 4 | 优惠券、分享继续增加差异 | 更多标志位、空 hook 和保护性注释 | 公共路径成为特例汇合点 |
第 2 阶段是边界变化的关键节点。布尔字段本身成本很低,但它把具体业务主语带入父类。第 3 阶段进一步将业务名称固化为父类 API,后续需求便会沿相同方向继续扩展。
这种演化通常来自成本分布不对称:在父类增加少量分支可以快速完成当前需求,而抽取策略或调整调用方需要同时修改测试和装配逻辑。当前改动获得局部收益,后续需求承担公共层持续膨胀的成本。因此,治理重点应放在设计约束和评审门禁,而不是依赖开发者临时克制。
设计原则:公共层不持有业务差异
“将 5% 抽成独立方法”只改变代码形态。判断结构是否改善,需要确认差异的选择权和实现是否已经离开公共类。
图 3:父类分支、策略和组合的核心差异,是业务决策权所在的位置。
| 方案 | 稳定部分 | 变化部分 | 新增一种业务差异 | 公共层是否识别具体业务 |
|---|---|---|---|---|
| 父类业务分支 | BaseRequest.send() | 父类中的 if、flag、专属方法 | 修改父类 | 是 |
| Template Method | 父类算法骨架 | 子类对中立 hook 的实现 | 新增子类实现 | 否 |
| Strategy | Request.send() | Signer 的具体实现 | 新增策略实现并完成装配 | 否 |
| 组合 | HttpClient、Signer 等通用组件 | PayApi、LoginApi 自己的流程 | 新增或修改业务编排 | 不存在业务父类 |
方案选择遵循四项原则:
- 公共层只保留共同契约和稳定骨架。 父类可以声明变化步骤并提供默认实现,但步骤的语义必须对抽象边界内的所有合法子类成立;单一业务知识留在对应业务边界内。
- 变化轴与扩展机制一致。 单一步骤变化使用策略,整体流程变化使用组合;不要用更多 hook 掩盖多个变化轴。
- 新增场景优先新增实现或装配。 如果每增加一种业务都要修改父类分支,说明差异边界仍然放错。
- 继承必须表达稳定的“是一种”关系。 代码相似度只能说明存在复用机会,不能证明两个业务对象属于同一继承体系。
方案选型
先识别差异类型,再决定安置位置:
| 差异类型 | 推荐方案 | 适用条件 | 避免的实现 |
|---|---|---|---|
| 骨架稳定,扩展步骤对所有子类语义一致 | Template Method | 存在稳定的“是一种”关系,hook 保持中立 | 用子类名称定义 hook |
| 骨架稳定,某一步算法可替换 | Strategy | 签名、排序、过滤等独立变化轴 | 在父类中用 if 选择算法 |
| 多个步骤可能变化,业务流程会继续分叉 | 组合 | 业务类可以持有通用组件并自行编排 | 为复用 95% 代码强行保留 BaseXxx |
| 仅代码形态相似,业务语义已经不同 | 保留适度重复,或抽取无状态工具 | 无稳定抽象或替换关系 | 为满足 DRY 构造错误父类 |
Template Method:仅保留稳定骨架
Template Method 适用于流程顺序稳定、子类确实属于同一种抽象、且变化步骤可以被中立命名的场景。前文的 BaseRequest.send() 与 calcSign() 满足这一条件:父类定义“构造参数—签名—发送”的固定顺序,子类只提供签名算法。
如果新增业务需要改变步骤顺序、跳过父类分支,或为父类增加新的业务 hook,就应重新评估继承关系。此时继续扩展 Template Method,只会把多条业务流程压进同一个骨架。
同一个签名示例既能用 Template Method,也能用 Strategy。两者都满足结构约束时,可按变化的生命周期选择:签名算法与请求子类型永久绑定、不会被其他对象复用,且骨架由父类统一控制时,Template Method 更直接;算法需要运行时切换、跨多类复用、独立测试或持有自己的依赖与状态时,Strategy 边界更清晰。不要只因“可以抽接口”就增加策略,也不要只因已有父类就默认继续继承。
Strategy:将单一变化轴对象化
当流程稳定、只有某一步算法不同,可将差异定义为策略接口,由装配方选择具体实现。策略不是简单的方法抽取:它同时移动了实现和选择权,使公共类只依赖抽象。
interface Signer {
String sign(Map<String, String> params);
}
class PaySigner implements Signer {
@Override
public String sign(Map<String, String> params) {
return md5(serialize(params) + payKey);
}
}
class LoginSigner implements Signer {
@Override
public String sign(Map<String, String> params) {
return hmac(serialize(params), loginKey);
}
}
class Request {
private final Signer signer;
Request(Signer signer) {
this.signer = signer;
}
void send() {
Map<String, String> params = buildParams();
params.put("sign", signer.sign(params));
http.post(url(), params);
}
}
new Request(new PaySigner()).send();
new Request(new LoginSigner()).send();Request 只认识 Signer,不认识 PaySigner、LoginSigner 或 needPaySign。新增签名算法时增加一个策略实现,并在装配边界完成选择,无需修改 Request.send()。工厂或依赖注入模块可以知道具体实现并完成一次集中映射,因为“选择实现”正是该层的职责;如果映射规则持续膨胀,应将其收敛为注册表或按业务拆分的工厂,而不是把选择逻辑放回 Request。
图 4:策略改变签名步骤的接收者,发送骨架保持不变。
策略并没有消除选择逻辑,而是将选择逻辑移动到更合适的装配边界。不要在 Request 内部重新增加 if (pay) 来实例化策略,否则决策权会再次回到公共类。
组合:由业务类编排通用能力
当差异不再局限于单一步骤,例如支付需要优惠券字段、登录需要设备指纹、分享使用完全不同的发送流程,统一的 BaseRequest 骨架已经不再稳定。此时应将可复用的 95% 下沉为无业务语义的组件,由平级业务类自行编排。
class HttpClient {
Result post(String url, Map<String, String> params) {
// 通用网络发送能力
}
}
class PayApi {
private final HttpClient http;
private final Signer signer;
void pay() {
Map<String, String> params = payParams();
params.put("sign", signer.sign(params));
http.post("/pay", params);
}
}
class LoginApi {
private final HttpClient http;
private final Signer signer;
void login() {
Map<String, String> params = loginParams();
params.put("sign", signer.sign(params));
http.post("/login", params);
}
}HttpClient 和 Signer 提供通用能力,PayApi 与 LoginApi 持有这些组件并负责各自的业务流程。支付和登录由此成为平级协作者,而不是被迫共享业务父类的两个子类。
图 5:业务类负责流程编排,Signer 和 HttpClient 只提供可复用能力。
这种结构与 领域驱动设计:用订单案例讲清领域建模与分层 中“业务规则不回流到应用服务”的原则一致:共享层一旦开始识别具体业务,就会逐渐成为所有特例的汇合点。Kotlin 的 by 委托可以简化部分对象协作语法,参见 Kotlin委托,但职责划分仍需先通过设计确定。
已渗透父类的迁移方案
迁移目标不是一次性消除所有继承,而是先移除父类中的业务知识,再根据剩余职责决定是否保留继承。
1. 建立现状清单与行为基线
梳理父类及其子类的以下内容:
- 父类中的业务
if、switch、标志位和类型判断; - 只被一个或少数子类使用的方法与 protected 字段;
- 子类中的空实现、绕行逻辑和调用顺序假设;
- 调用入口、生命周期约束及现有测试覆盖。
重构前应为关键行为建立基线测试,至少覆盖请求参数、调用顺序、成功结果和失败分支。父类绑定全局生命周期、子类数量多且缺少测试时,迁移风险主要来自行为不可见,而不是语法修改本身。
2. 按差异归属拆分职责
对清单中的差异逐项处理:
- 下沉单一业务逻辑。 仅属于支付的
doPayExtra()移入PayApi,不再作为父类 hook。 - 横向抽取可替换算法。 多个业务需要不同签名算法时,抽取
Signer及其实现。 - 下沉稳定公共能力。 网络发送、序列化等无业务语义的能力拆为
HttpClient等组件。 - 收缩父类契约。 删除已无调用的业务标志位、专属方法和 protected 状态。
3. 决定继承关系的最终形态
父类变薄后重新判断:
- 如果仍存在稳定骨架和中立 hook,可保留 Template Method;
- 如果只剩通用能力,改为“通用组件 + 平级业务类”;
- 如果剩余代码仅表面相似,允许保留适度重复,避免重新制造错误抽象。
4. 验证迁移结果
迁移完成后至少满足以下条件:
- 公共类中不再出现具体业务名称、类型判断和业务标志位;
- 新增一种签名算法只需增加
Signer实现并修改装配; - 业务子类不再包含空 hook 或绕开父类流程的覆盖方法;
- 原有成功、失败和调用顺序测试保持通过;
- 父类剩余方法都能用同一语义解释给所有合法子类。
代码评审检查项
- 父类字段、方法名或控制流中是否出现具体子类或业务名称?
- 是否存在
instanceof、业务switch或专属标志位来选择子类行为? - hook 的名称、参数和返回值是否对所有合法子类具有一致语义?
- 新增一种业务差异时,是增加实现与装配,还是修改公共父类?
- 95% 的相似代码是否来自稳定的“是一种”关系,还是两段暂时相似的流程?
- 是否存在空实现、绕行分支或 protected 状态,表明父类暴露了无关契约?
参考资料
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns:Template Method、Strategy、object composition
- Joshua Bloch, Effective Java(第 3 版)第 18 条:Favor composition over inheritance
- Leonid Mikhajlov, Emil Sekerinski, A Study of The Fragile Base Class Problem, ECOOP 1998
- Martin Fowler, Protected Data
- Kotlin委托:通过对象复用行为,避免不必要地延长继承树
相关笔记
- 领域驱动设计:用订单案例讲清领域建模与分层
- Kotlin委托
- CleanArchitecture整洁架构
- Event Sourcing:事件、投影与读模型
评论