文档概述
背景
继承体系中常见一类演化问题:多个子类可以复用父类约 95% 的流程,只有少量业务行为不同。为了降低当次需求的改动成本,差异通常会被写回父类,例如增加业务标志位、条件分支,或新增只服务于某个子类的 hook。随着子类增多,父类逐渐成为所有业务特例的汇合点,公共路径的修改和回归成本持续上升。
问题不在于差异代码占 5% 还是 50%,也不在于是否已经“抽成方法”,而在于:谁拥有差异的决策权。 如果父类仍负责识别业务类型并选择分支,差异就没有离开公共层。
目标与范围
本文以“构造请求并签名”为统一示例,说明以下问题:
- 如何识别子类特例已经渗透到父类;
- 如何判断一个 hook 是否仍是中立扩展点;
- 如何在 Template Method、Strategy 和组合之间做选择;
- 选择具体实现的判断应落在哪一层;
- 如何迁移已经被业务分支污染的父类。
示例代码为简化后的 Java 片段,用于说明依赖关系和职责边界,不对应具体仓库实现,也不展开网络协议、加密算法或依赖注入框架的细节。
**核心结论:**父类应表达抽象边界内共同成立的契约和稳定骨架,变化步骤可以由子类实现,但父类不应识别某一个分支的专属知识。跨业务父类一旦出现具体子类名称、专属标志位、类型判断或
doXxxExtra()一类业务化 hook,应将差异下沉到业务类,或横向抽取为策略和可组合组件。Template Method、Strategy 和组合都不会消灭选择逻辑;它们只是把“用哪一个实现”从公共骨架移到调用方、业务类或装配边界。
问题定义:子类特例渗透父类
本文将“子类特例渗透父类”定义为:原本只属于某个子类或业务场景的知识,反向进入父类的字段、方法签名或控制流。 这会让父类从“定义公共契约”转变为“识别并照顾具体业务”。
本文中的“公共层”特指跨多个业务场景复用的抽象,例如同时服务支付、登录和分享的 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% 抽成独立方法”只改变代码形态。判断结构是否改善,需要确认算法实现是否已经离开公共类,以及选择具体实现的判断是否仍留在公共 send() 路径里。实现可以下沉到策略或业务类;选择仍会存在,只是不应再由公共层识别业务。
图 3:父类分支把实现和选择都留在公共类;策略和组合只把实现挪走,选择仍在业务类或装配层。
| 方案 | 稳定部分 | 变化部分 | 选择具体实现的位置 | 新增一种业务差异 | 公共层是否识别具体业务 |
|---|---|---|---|---|---|
| 父类业务分支 | BaseRequest.send() | 父类中的 if、flag、专属方法 | send() 内部 | 修改父类 | 是 |
| Template Method | 父类算法骨架 | 子类对中立 hook 的实现 | 调用方选择子类 | 新增子类实现 | 否 |
| Strategy | Request.send() | Signer 的具体实现 | 业务类或装配层 | 新增策略实现并完成装配 | 否 |
| 组合 | HttpClient、Signer 等通用组件 | PayApi、LoginApi 自己的流程 | 入口选用例,业务类或装配层持有组件 | 新增或修改业务编排 | 不存在业务父类 |
方案选择遵循四项原则:
- 公共层只保留共同契约和稳定骨架。 父类可以声明变化步骤并提供默认实现,但步骤的语义必须对抽象边界内的所有合法子类成立;单一业务知识留在对应业务边界内。
- 变化轴与扩展机制一致。 单一步骤变化使用策略,整体流程变化使用组合;不要用更多 hook 掩盖多个变化轴。
- 新增场景优先新增实现或装配。 如果每增加一种业务都要修改父类分支,说明差异边界仍然放错。入口或工厂里可以有选用例、选实现的判断;公共
send()路径里不能再出现同一份业务识别。 - 继承必须表达稳定的“是一种”关系。 代码相似度只能说明存在复用机会,不能证明两个业务对象属于同一继承体系。
方案选型
先识别差异类型,再决定安置位置:
| 差异类型 | 推荐方案 | 适用条件 | 避免的实现 |
|---|---|---|---|
| 骨架稳定,扩展步骤对所有子类语义一致 | Template Method | 存在稳定的“是一种”关系,hook 保持中立 | 用子类名称定义 hook |
| 骨架稳定,某一步算法可替换 | Strategy | 签名、排序、过滤等独立变化轴 | 在父类或 send() 里用 if 选择、实例化算法 |
| 多个步骤可能变化,业务流程会继续分叉 | 组合 | 业务类可以持有通用组件并自行编排 | 为复用 95% 代码强行保留 BaseXxx |
| 仅代码形态相似,业务语义已经不同 | 保留适度重复,或抽取无状态工具 | 无稳定抽象或替换关系 | 为满足 DRY 构造错误父类 |
Template Method:仅保留稳定骨架
Template Method 适用于流程顺序稳定、子类确实属于同一种抽象、且变化步骤可以被中立命名的场景。前文的 BaseRequest.send() 与 calcSign() 满足这一条件:父类定义“构造参数—签名—发送”的固定顺序,子类只提供签名算法。
如果新增业务需要改变步骤顺序、跳过父类分支,或为父类增加新的业务 hook,就应重新评估继承关系。此时继续扩展 Template Method,只会把多条业务流程压进同一个骨架。
同一个签名示例既能用 Template Method,也能用 Strategy。两者都满足结构约束时,可按变化的生命周期选择:签名算法与请求子类型永久绑定、不会被其他对象复用,且骨架由父类统一控制时,Template Method 更直接;算法需要运行时切换、跨多类复用、独立测试或持有自己的依赖与状态时,Strategy 边界更清晰。不要只因“可以抽接口”就增加策略,也不要只因已有父类就默认继续继承。
Strategy:将单一变化轴对象化
当流程稳定、只有某一步算法不同,可将差异定义为策略接口,由装配方选择具体实现。策略不是简单的方法抽取:它把算法实现从公共类移走,使公共类只依赖抽象;选择哪一个实现则离开 send(),落到业务类或装配边界。
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);
}
}Request 只认识 Signer,不认识 PaySigner、LoginSigner 或 needPaySign。新增签名算法时增加一个策略实现,并在装配边界完成选择,无需修改 Request.send()。谁实例化 PaySigner,见后文「选择逻辑的落点」。
图 4:发送时序里没有业务 if;具体 Signer 在进入 send() 之前已经选好。
组合:由业务类编排通用能力
当差异不再局限于单一步骤,例如支付需要优惠券字段、登录需要设备指纹、分享使用完全不同的发送流程,统一的 BaseRequest 骨架已经不再稳定。此时应将可复用的 95% 下沉为无业务语义的组件,由平级业务类自行编排。
class HttpClient {
Result post(String url, Map<String, String> params) {
// 通用网络发送能力
}
}
class PayApi {
private final HttpClient http;
private final Signer signer;
PayApi(HttpClient http, Signer signer) {
this.http = http;
this.signer = signer; // 构造时传入 PaySigner
}
void pay() {
Map<String, String> params = payParams();
params.put("sign", signer.sign(params));
http.post("/pay", params); // 方法内部不再 if (pay)
}
}
class LoginApi {
private final HttpClient http;
private final Signer signer;
LoginApi(HttpClient http, Signer signer) {
this.http = http;
this.signer = signer; // 构造时传入 LoginSigner
}
void login() {
Map<String, String> params = loginParams();
params.put("sign", signer.sign(params));
http.post("/login", params);
}
}HttpClient 和 Signer 提供通用能力,PayApi 与 LoginApi 持有这些组件并负责各自的业务流程。支付和登录由此成为平级协作者,而不是被迫共享业务父类的两个子类。pay() 内部没有业务 if,并不表示判断消失:选择发生在“创建了 PayApi 而不是 LoginApi”,以及构造时传入哪一个 Signer。下一节会把同一结构写成 PayApi 持有 Request,只是把发送骨架收成一个对象,判断落点不变。
图 5:业务类负责流程编排,Signer 和 HttpClient 只提供可复用能力。
这种结构与 领域驱动设计:用订单案例讲清领域建模与分层 中“业务规则不回流到应用服务”的原则一致:共享层一旦开始识别具体业务,就会逐渐成为所有特例的汇合点。Kotlin 的 by 委托可以简化部分对象协作语法,参见 Kotlin委托,但职责划分仍需先通过设计确定。
选择逻辑的落点
组合、策略和 Template Method 都不会消灭判断。需要移走的是公共发送路径里的业务识别。
Template Method 的选择发生在调用方 new PayRequest(),而不是 BaseRequest.send() 内的 instanceof。策略把同一份选择换成“传入哪一个 Signer”;组合再把它拆成“入口调用 pay() 还是 login()”,以及“PayApi 持有哪些组件”。
一次支付请求必须使用 PaySigner。系统仍然要知道这件事,差别只在于这个知识出现在哪一层。
| 判断类型 | 出现位置 | 是否进入公共发送路径 | 典型形态 |
|---|---|---|---|
| 选择子类 | 调用方构造 Template Method 子类 | 否 | new PayRequest() |
| 入口分流 | 按钮、路由、用例入口 | 否 | onPayClicked() 调用 payApi.pay() |
| 业务类持有 | PayApi 的字段或构造 | 否 | 类名已经决定使用 PaySigner |
| 装配映射 | 工厂、DI 模块、注册表 | 否 | 创建时 switch / Map / 绑定图 |
| 公共路径识别 | Request.send()、BaseRequest | 是 | if (needPaySign) 或 if (pay) new PaySigner() |
入口分流看起来像 if-else,但它选择的是用例,不是在共享骨架里认业务。用户点了支付按钮,入口本来就知道这是支付;它应调用 PayApi,而不是把“支付还是登录”当作标志位传进 BaseRequest。
业务类组合后,PayApi 内部通常不再写 if (pay)。判断已经折叠进对象身份:支付模块持有 PaySigner,登录模块持有 LoginSigner。调用 pay() 的代码不必看见 Signer。
class PayApi {
private final Request request = new Request(new PaySigner());
void pay() {
request.send();
}
}
class LoginApi {
private final Request request = new Request(new LoginSigner());
void login() {
request.send();
}
}
void onPayClicked() {
payApi.pay();
}
void onLoginClicked() {
loginApi.login();
}这里仍然有判断,只是判断发生在入口选用例、业务类持有具体策略,而不是 Request.send() 运行时再识别一次。
当 Request 被多处创建,或具体 Signer 需要按配置、测试替换时,把映射收到装配层。工厂可以知道 PaySigner 和 LoginSigner,因为“创建对象并选择实现”是该层的职责:
class SignerFactory {
Signer forScene(Scene scene) {
switch (scene) {
case PAY: return new PaySigner();
case LOGIN: return new LoginSigner();
default: throw new IllegalArgumentException(scene.name());
}
}
}
void onPayClicked() {
new Request(signerFactory.forScene(Scene.PAY)).send();
}工厂里的 switch 可以存在,但它不是治理完成的标志。调用工厂的代码必须已经拥有 scene,例如支付入口传入 Scene.PAY。如果每个通用 send() 调用点都要先判断这是支付还是登录,再把结果交给工厂,选择逻辑只是从父类搬到了散落的调用点。映射膨胀时应收成注册表,或按业务拆分工厂,而不是继续维持一个认识所有业务的超级工厂。两种合法落点也可以叠用:支付模块的依赖图在启动时绑死 PaySigner,PayApi 只持有已经选好的依赖。
禁止把同一份判断写回公共类。下面这种写法只是把原来的 needPaySign 换成了策略对象,决策权仍在共享路径里:
class Request {
void send(boolean pay) {
Signer signer = pay ? new PaySigner() : new LoginSigner();
Map<String, String> params = buildParams();
params.put("sign", signer.sign(params));
http.post(url(), params);
}
}图 6:入口分流和装配映射都是合法判断;Request.send() 内的业务 if 不是。
已渗透父类的迁移方案
迁移目标不是一次性消除所有继承,而是先移除父类中的业务知识,再根据剩余职责决定是否保留继承。
1. 建立现状清单与行为基线
梳理父类及其子类的以下内容:
- 父类中的业务
if、switch、标志位和类型判断; - 只被一个或少数子类使用的方法与 protected 字段;
- 子类中的空实现、绕行逻辑和调用顺序假设;
- 调用入口、生命周期约束及现有测试覆盖。
重构前应为关键行为建立基线测试,至少覆盖请求参数、调用顺序、成功结果和失败分支。父类绑定全局生命周期、子类数量多且缺少测试时,迁移风险主要来自行为不可见,而不是语法修改本身。
2. 按差异归属拆分职责
对清单中的差异逐项处理:
- 下沉单一业务逻辑。 仅属于支付的
doPayExtra()移入PayApi,不再作为父类 hook。 - 横向抽取可替换算法。 多个业务需要不同签名算法时,抽取
Signer及其实现。 - 下沉稳定公共能力。 网络发送、序列化等无业务语义的能力拆为
HttpClient等组件。 - 收缩父类契约。 删除已无调用的业务标志位、专属方法和 protected 状态。
3. 决定继承关系的最终形态
父类变薄后重新判断:
- 如果仍存在稳定骨架和中立 hook,可保留 Template Method;
- 如果只剩通用能力,改为“通用组件 + 平级业务类”;
- 如果剩余代码仅表面相似,允许保留适度重复,避免重新制造错误抽象。
4. 验证迁移结果
迁移完成后至少满足以下条件:
- 公共类中不再出现具体业务名称、类型判断和业务标志位;
- 新增一种签名算法只需增加
Signer实现并修改装配;选择具体实现的if/switch不在Request.send()内; - 业务子类不再包含空 hook 或绕开父类流程的覆盖方法;
- 原有成功、失败和调用顺序测试保持通过;
- 父类剩余方法都能用同一语义解释给所有合法子类。
代码评审检查项
- 父类字段、方法名或控制流中是否出现具体子类或业务名称?
- 是否存在
instanceof、业务switch或专属标志位来选择子类行为? - hook 的名称、参数和返回值是否对所有合法子类具有一致语义?
- 新增一种业务差异时,是增加实现与装配,还是修改公共父类?
- 选择具体实现的
if/switch是在业务类或装配层,还是在公共send()路径里? - 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:事件、投影与读模型
评论