31 继承税
评估继承带来的语义与变更耦合,优先用接口、委托和 mixin 表达多态与复用。
学习目标
- 能画出继承税如何从基类耦合、接口合同、委托边界传到调用者
- 能把只为复用而建立的继承关系改成组合,并用同一组输入证明调用合同不变
- 能在多态替换失败时指出首差、责任人和回退动作,而不是只报告最终结果
先定义要付的税
继承不是“永远错误”,但它会把父类的语义、初始化顺序和变更风险一起带给子类。这里把这种被动承担的成本称为继承税:一次看似省掉的重复代码,可能换来更窄的替换空间、更长的测试路径和更难解释的失败。
本章依据 David Thomas、Andrew Hunt《程序员修炼之道:通向务实的最高境界(第2版)》,云风译,电子工业出版社,2020 年 4 月,ISBN 9787121384356 的公开中文目录,独立重构 31 继承税、提示51:不要付继承税、提示52:尽量用接口来表达多态、提示53:用委托提供服务:“有一个”胜过“是一个” 与 提示54:利用 mixin 共享功能。下文的模型、代码、图示、实验和练习均为本课程重新设计,不复制原书正文、插图或答案。
验收对象是一组“通知发送器”:应用只需要 send(message),不应被某个供应商的重试、构造函数或日志字段绑住。先固定输入、输出和失败条件,再比较继承、接口、委托与 mixin 四种组织方式。
先看失败边界
五个概念,五个可检查的决定
31 继承税:复用是否值得耦合
<Term def="因继承关系自动承担父类语义、初始化、变更和测试影响的额外成本。">继承税</Term>不是一个虚构分数,而是一份影响清单:父类新增抽象方法时谁会被迫修改?父类构造函数改变时谁会失效?子类能否在不读取父类内部字段的情况下替换?如果答不出,复用带来的短期收益就还没有抵消长期耦合。
提示51:不要付继承税
提示51的可执行解释是:当关系只是“想借一段实现”而不是严格的“是一个”时,不要用继承支付整套父类合同。先记录想复用的一个能力,再比较复制一小段、委托一个服务和使用受限 mixin 的边界;能够删除父类而保持调用者合同不变,才说明税确实被避免。
提示52:尽量用接口来表达多态
<Term def="只描述调用者需要的能力与输入输出合同、不暴露实现层级的边界。">接口</Term>把多态的共同点放在可验证的行为上。Notifier 不需要知道邮件客户端是否继承了某个基类,只要每个实现都接受 Message 并返回 DeliveryResult。接口越小,替换测试越容易覆盖;接口若包含无关方法,就会把不需要的税转移给实现者。
提示53:用委托提供服务:“有一个”胜过“是一个”
<Term def="对象把一个明确的能力请求转发给内部协作者,而不是通过继承获得父类身份。">委托</Term>让所有权和失败路径变得显式。DigestNotifier 可以拥有一个 Transport,在边界处把超时、重试和审计结果转换成自己的合同;替换传输实现不会改变通知器的身份,也不会让调用者看到传输的私有状态。
提示54:利用 mixin 共享功能
<Term def="在明确宿主合同下横向注入一小段可复用行为的组合机制。">mixin</Term>适合无状态、边界清楚的横切能力,例如给多个适配器添加统一的追踪标签。它不应偷偷依赖某个父类的字段或初始化顺序。若 mixin 的宿主要求已经接近一棵继承树,优先改成普通函数或委托对象。
用同一合同比较四种方案
下面的合同只暴露一个能力:输入一条消息,成功返回投递编号,失败返回稳定的原因码。四种方案都必须满足它;区别在于复用代码的边界是否让调用者承担额外知识。
type Message = { id: string; body: string };
type DeliveryResult =
| { ok: true; deliveryId: string }
| { ok: false; reason: "timeout" | "rejected" };
interface Notifier {
send(message: Message): Promise<DeliveryResult>;
}
class DigestNotifier implements Notifier {
constructor(private readonly transport: Notifier) {}
send(message: Message): Promise<DeliveryResult> {
return this.transport.send({ ...message, body: `[digest] ${message.body}` });
}
}这里的委托不是把复杂性藏起来:DigestNotifier 明确拥有一个满足 Notifier 的协作者,并把消息变换写在边界上。测试可以传入内存实现,生产组合根再传入邮件或队列适配器;调用者不必继承任何供应商类。
观察点:替换而不是猜测
图中从需求开始,经过接口、委托、组合到替换。每一步都显示当前责任人、输入和拒绝原因;红色路径表示一个具体实现改变了不属于它的合同。先预测:如果把邮件适配器替换成队列适配器,调用者应该看到什么变化?再打开实验台注入一次故障。
逐步观察:把复用责任从父类层级退回可替换的能力边界。
第 1 / 5 步:需求 已留下合同证据。
题目覆盖:31 继承税 · 提示51:不要付继承税 · 提示52:尽量用接口来表达多态 · 提示53:用委托提供服务:“有一个”胜过“是一个” · 提示54:利用 mixin 共享功能
为什么组合边界更容易回滚
如果把邮件适配器换成队列适配器,调用者应该只看到同一输入、同一结果形状和稳定的失败原因;这就是 <Term def="用另一实现替换协作者后仍必须成立的行为约束。">替换合同</Term>。先预测,再在实验台中只注入一个故障,才能把“更容易回滚”变成可观察证据。
三步审查:从层级退回能力
1. 写出最小接口和替换样本
需求与接口:只写调用者真正需要的 send 合同
记录一个正常消息、一个空消息和一个传输超时。只写调用者真正需要的 send 合同,并把预期结果固定下来;不要先搬运父类的全部方法。
正常、边界与单故障矩阵
| 样本 | 唯一变化 | 预期 | 必存证据 |
|---|---|---|---|
| 正常 | 合法消息与稳定传输 | 任何实现都返回同形状成功结果 | 输入、接口版本、投递编号 |
| 边界 | 空正文或超长正文 | 在接口边界拒绝,不进入外部传输 | 原始输入、原因码、接收者 |
| 单故障 | 仅传输实现超时 | 调用者收到 timeout,不伪造成功 | 首差、重试次数、恢复动作 |
三类样本共用消息 ID、接口版本和组合根。若同时替换接口、实现与重试策略,就无法判断继承税是从哪里产生的;先恢复基线,再只改一个变量。
何时选择 mixin,何时选择委托
横向能力可以用 mixin,但先问两个问题:它是否读取宿主之外的隐含状态?方法名冲突时谁拥有最终解释权?答案若不稳定,就使用一个显式委托对象。委托多一行构造代码,却把生命周期、错误和观测责任放在可搜索的位置。
一个安全的 mixin 只接收参数并返回值,例如 withTraceLabel(message, label);它不假定宿主拥有 logger、retryCount 或某个父类私有字段。一个安全的委托则通过接口注入依赖,允许测试用假实现证明替换行为。两者都比“为了得到一个方法而继承具体类”更容易回滚。
复核记录
unit: tpp20-topic-31-inheritance-tax
contract: Notifier.send(Message) -> DeliveryResult
baseline: stable-transport
intervention: transport-timeout-only
first_difference: replacement-boundary
evidence: input-id-interface-version-reason-code
recovery: restore-stable-adapter-and-replay独立复核者只需要合同、固定输入和唯一干预,就能重建结论。若结果不同,先比较接口版本与首差,再检查组合根;不要用“它原来是父类”替代可观察证据。
本章回顾
- 继承税是被动承担父类语义和变更风险的成本,不是抽象分数。
- 接口表达能力合同,多态就可以围绕行为而不是层级组织。
- 委托让“有一个”关系、所有权和失败路径显式可查。
- mixin 适合小而有明确宿主合同的横向行为;隐式状态应退回委托。
- 通过相同输入和单故障替换,才能证明调用合同没有被复用方式绑架。
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 继承税
- 因继承自动承担父类语义、初始化和变更影响的成本。
- 接口
- 描述能力输入、输出和失败合同的边界。
- 委托
- 把能力请求转发给一个明确协作者的组合关系。
- mixin
- 在宿主合同明确时横向加入一小段共享行为。
- 替换合同
- 用另一实现替换协作者后仍必须成立的行为约束。
- 首差
- 第一次偏离预期合同、最早能解释后续错误的可观察变化。
可验证练习
练习
问题 1:识别继承税。 一个 CachedNotifier 继承供应商客户端,只为了复用 format 方法。列出至少两项隐含税,并给出更小的替换边界。
问题 2:设计委托。 将 EmailNotifier 改造成拥有一个 Transport 的对象。空正文和超时分别由谁拒绝,测试应观察什么?
问题 3:限制 mixin。 给多个适配器增加追踪标签时,怎样证明 mixin 没有偷读宿主状态?