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 的协作者,并把消息变换写在边界上。测试可以传入内存实现,生产组合根再传入邮件或队列适配器;调用者不必继承任何供应商类。

观察点:替换而不是猜测

图中从需求开始,经过接口、委托、组合到替换。每一步都显示当前责任人、输入和拒绝原因;红色路径表示一个具体实现改变了不属于它的合同。先预测:如果把邮件适配器替换成队列适配器,调用者应该看到什么变化?再打开实验台注入一次故障。

继承税:从“是一个”退回“有一个”需求 → 接口 → 委托 → 组合 → 替换;每个边界都留下输入、结果和拒绝原因合同send(Message)DeliveryResultTransport组合根替换仍成立1需求只需 sendMessage → Result调用者不认识供应商2接口Notifier 合同ok / timeout表达能力,不表达层级3委托拥有 Transporthas-a boundary依赖由组合根注入4组合选择具体实现Email / Queue替换不会改调用者5替换合同仍成立same input → result首差可定位、可回放验收合同:同一输入可替换实现,调用者只看能力结果接口表达多态,委托表达服务;mixin 只共享无状态、可列出的横向行为先预测首差,再推进一个节点;不要把父类知识泄漏给所有调用者当前观察点:需求 · 只需 send

逐步观察:把复用责任从父类层级退回可替换的能力边界。

继承税不是抽象分数,而是父类语义、初始化顺序和变更风险沿着继承边进入调用者的路径。
Topic 31 · 继承税实验台
继承税:从“是一个”退回“有一个”需求 → 接口 → 委托 → 组合 → 替换;每个边界都留下输入、结果和拒绝原因合同send(Message)DeliveryResultTransport组合根替换仍成立1需求只需 sendMessage → Result调用者不认识供应商2接口Notifier 合同ok / timeout表达能力,不表达层级3委托拥有 Transporthas-a boundary依赖由组合根注入4组合选择具体实现Email / Queue替换不会改调用者5替换合同仍成立same input → result首差可定位、可回放验收合同:同一输入可替换实现,调用者只看能力结果接口表达多态,委托表达服务;mixin 只共享无状态、可列出的横向行为先预测首差,再推进一个节点;不要把父类知识泄漏给所有调用者当前观察点:需求 · 只需 send

第 1 / 5 步:需求 已留下合同证据。

题目覆盖:31 继承税 · 提示51:不要付继承税 · 提示52:尽量用接口来表达多态 · 提示53:用委托提供服务:“有一个”胜过“是一个” · 提示54:利用 mixin 共享功能

为什么组合边界更容易回滚

如果把邮件适配器换成队列适配器,调用者应该只看到同一输入、同一结果形状和稳定的失败原因;这就是 <Term def="用另一实现替换协作者后仍必须成立的行为约束。">替换合同</Term>。先预测,再在实验台中只注入一个故障,才能把“更容易回滚”变成可观察证据。

三步审查:从层级退回能力

分步1 / 3

1. 写出最小接口和替换样本

继承税:从“是一个”退回“有一个”需求 → 接口 → 委托 → 组合 → 替换;每个边界都留下输入、结果和拒绝原因合同send(Message)DeliveryResultTransport组合根替换仍成立1需求只需 sendMessage → Result调用者不认识供应商2接口Notifier 合同ok / timeout表达能力,不表达层级3委托拥有 Transporthas-a boundary依赖由组合根注入4组合选择具体实现Email / Queue替换不会改调用者5替换合同仍成立same input → result首差可定位、可回放验收合同:同一输入可替换实现,调用者只看能力结果接口表达多态,委托表达服务;mixin 只共享无状态、可列出的横向行为先预测首差,再推进一个节点;不要把父类知识泄漏给所有调用者当前观察点:需求 · 只需 send

需求与接口:只写调用者真正需要的 send 合同

继承税不是抽象分数,而是父类语义、初始化顺序和变更风险沿着继承边进入调用者的路径。

记录一个正常消息、一个空消息和一个传输超时。只写调用者真正需要的 send 合同,并把预期结果固定下来;不要先搬运父类的全部方法。

正常、边界与单故障矩阵

样本唯一变化预期必存证据
正常合法消息与稳定传输任何实现都返回同形状成功结果输入、接口版本、投递编号
边界空正文或超长正文在接口边界拒绝,不进入外部传输原始输入、原因码、接收者
单故障仅传输实现超时调用者收到 timeout,不伪造成功首差、重试次数、恢复动作

三类样本共用消息 ID、接口版本和组合根。若同时替换接口、实现与重试策略,就无法判断继承税是从哪里产生的;先恢复基线,再只改一个变量。

何时选择 mixin,何时选择委托

横向能力可以用 mixin,但先问两个问题:它是否读取宿主之外的隐含状态?方法名冲突时谁拥有最终解释权?答案若不稳定,就使用一个显式委托对象。委托多一行构造代码,却把生命周期、错误和观测责任放在可搜索的位置。

一个安全的 mixin 只接收参数并返回值,例如 withTraceLabel(message, label);它不假定宿主拥有 loggerretryCount 或某个父类私有字段。一个安全的委托则通过接口注入依赖,允许测试用假实现证明替换行为。两者都比“为了得到一个方法而继承具体类”更容易回滚。

复核记录

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 没有偷读宿主状态?

资料与写作方式声明

本章以程序员修炼之道权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

前后导航

讨论

评论区加载中…