文档概述

背景

继承体系中常见一类演化问题:多个子类可以复用父类约 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() 的问题不是代码量,而是扩展点失去中立性。登录、分享和优惠券请求都被迫继承与支付有关的契约;增加下一个特例时,团队也更容易继续修改父类。

干净 hook 与被点名的 hook

图 1:左侧父类只定义中立的 calcSign();右侧父类通过业务标志位和专属 hook 持有差异决策。

满足以下条件时,hook 通常可以保留在父类中:

  1. 方法名称和参数不包含具体子类或业务名称;
  2. 所有合法子类都能用同一种语义解释该步骤;
  3. 新增子类不需要修改父类控制流;
  4. 子类无需通过无意义的空实现或反向覆盖来绕开父类行为。

默认空实现本身不是错误。如果“可选步骤”对所有子类都有一致含义,只是部分子类无需执行动作,no-op 可以是合法默认值。若该能力只对一组子类成立,应优先考虑更窄的中间抽象、能力接口或装饰器,而不是让整棵继承树暴露无关 hook。

渗透的典型演化路径

父类通常不是一次性变得复杂,而是在连续的局部最优修改中逐步失去边界。

Hook 被点名之后的演化

图 2:从中立扩展点到业务特例汇合点的典型演化过程。

阶段当次改动父类新增内容长期影响
1抽取中立的 calcSign()稳定扩展点所有子类共享同一语义
2支付签名不一致,增加 needPaySign公共路径中的业务分支父类开始识别支付场景
3支付还需补充字段,增加 doPayExtra()单一业务专属 hook其他子类继承无关契约
4优惠券、分享继续增加差异更多标志位、空 hook 和保护性注释公共路径成为特例汇合点

第 2 阶段是边界变化的关键节点。布尔字段本身成本很低,但它把具体业务主语带入父类。第 3 阶段进一步将业务名称固化为父类 API,后续需求便会沿相同方向继续扩展。

这种演化通常来自成本分布不对称:在父类增加少量分支可以快速完成当前需求,而抽取策略或调整调用方需要同时修改测试和装配逻辑。当前改动获得局部收益,后续需求承担公共层持续膨胀的成本。因此,治理重点应放在设计约束和评审门禁,而不是依赖开发者临时克制。

设计原则:公共层不持有业务差异

“将 5% 抽成独立方法”只改变代码形态。判断结构是否改善,需要确认差异的选择权和实现是否已经离开公共类。

那 5% 的决策权在谁手里

图 3:父类分支、策略和组合的核心差异,是业务决策权所在的位置。

方案稳定部分变化部分新增一种业务差异公共层是否识别具体业务
父类业务分支BaseRequest.send()父类中的 if、flag、专属方法修改父类是
Template Method父类算法骨架子类对中立 hook 的实现新增子类实现否
StrategyRequest.send()Signer 的具体实现新增策略实现并完成装配否
组合HttpClient、Signer 等通用组件PayApi、LoginApi 自己的流程新增或修改业务编排不存在业务父类

方案选择遵循四项原则:

  1. 公共层只保留共同契约和稳定骨架。 父类可以声明变化步骤并提供默认实现,但步骤的语义必须对抽象边界内的所有合法子类成立;单一业务知识留在对应业务边界内。
  2. 变化轴与扩展机制一致。 单一步骤变化使用策略,整体流程变化使用组合;不要用更多 hook 掩盖多个变化轴。
  3. 新增场景优先新增实现或装配。 如果每增加一种业务都要修改父类分支,说明差异边界仍然放错。
  4. 继承必须表达稳定的“是一种”关系。 代码相似度只能说明存在复用机会,不能证明两个业务对象属于同一继承体系。

方案选型

先识别差异类型,再决定安置位置:

差异类型推荐方案适用条件避免的实现
骨架稳定,扩展步骤对所有子类语义一致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。

策略模式下 send 的调用时序

图 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. 按差异归属拆分职责

对清单中的差异逐项处理:

  1. 下沉单一业务逻辑。 仅属于支付的 doPayExtra() 移入 PayApi,不再作为父类 hook。
  2. 横向抽取可替换算法。 多个业务需要不同签名算法时,抽取 Signer 及其实现。
  3. 下沉稳定公共能力。 网络发送、序列化等无业务语义的能力拆为 HttpClient 等组件。
  4. 收缩父类契约。 删除已无调用的业务标志位、专属方法和 protected 状态。

3. 决定继承关系的最终形态

父类变薄后重新判断:

  • 如果仍存在稳定骨架和中立 hook,可保留 Template Method;
  • 如果只剩通用能力,改为“通用组件 + 平级业务类”;
  • 如果剩余代码仅表面相似,允许保留适度重复,避免重新制造错误抽象。

4. 验证迁移结果

迁移完成后至少满足以下条件:

  • 公共类中不再出现具体业务名称、类型判断和业务标志位;
  • 新增一种签名算法只需增加 Signer 实现并修改装配;
  • 业务子类不再包含空 hook 或绕开父类流程的覆盖方法;
  • 原有成功、失败和调用顺序测试保持通过;
  • 父类剩余方法都能用同一语义解释给所有合法子类。

代码评审检查项

  1. 父类字段、方法名或控制流中是否出现具体子类或业务名称?
  2. 是否存在 instanceof、业务 switch 或专属标志位来选择子类行为?
  3. hook 的名称、参数和返回值是否对所有合法子类具有一致语义?
  4. 新增一种业务差异时,是增加实现与装配,还是修改公共父类?
  5. 95% 的相似代码是否来自稳定的“是一种”关系,还是两段暂时相似的流程?
  6. 是否存在空实现、绕行分支或 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委托:通过对象复用行为,避免不必要地延长继承树

相关笔记