12.7 单表继承
用一张包含全部子类字段的表表示整个继承层次,并以类型标识区分具体类型。
学习目标
- 能解释单表继承如何用鉴别器列把多个具体子类恢复到同一对象入口
- 能实现类型字段与子类字段的表级约束,并用一条错误数据证明约束会拒绝它
- 能回答:当查询、字段变化或迁移成本超过什么边界时,应改用类表继承或具体表继承
为什么 12.7 单表继承值得单独学习
一个 Employee 对象可能有 Engineer 和 Manager 两种具体形态。对象模型希望保留继承关系,让调用方通过同一个入口处理员工;关系数据库却更喜欢固定列。↡把一个继承层次的公共字段与各子类字段放进同一张表,并用类型字段区分具体类型的映射模式就是在这两个愿望之间设一道明确的映射边界:每行都有公共字段和一个类型标识,子类特有字段也位于同一张表中。
它的收益很直接:读取一个员工集合通常不需要在多张子类表之间 JOIN,多态列表也能沿着一张表的索引完成。代价同样直接:每个子类都会给表增加自己的列,其他类型的行只能把这些列留空;约束、索引和迁移会随着继承树变宽。真正要学会的不是“所有字段塞进一张表”,而是能说清楚一行数据为什么会被恢复为某个子类,以及何时应该拒绝这条路。
本章以 Employee → Engineer / Manager 为固定例子,沿“对象树 → 鉴别器路由 → 单表约束”建立证据链。案例、代码、练习和 SVG 教具均为本课程独立重写;它们只用 Martin Fowler 的公开目录和模式摘要限定本章范围,不复现原书正文或插图。
先建立直觉:一张表怎样承载一棵对象树
在对象侧,Engineer 和 Manager 共享 id、name、version 等基础状态,同时保留自己的 skill 或 budgetLimit。在表侧,employees 可以写成以下最小结构:
CREATE TABLE employees (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
skill TEXT,
budget_limit DECIMAL(12, 2),
version INTEGER NOT NULL DEFAULT 0,
CHECK (kind IN ('engineer', 'manager'))
);这里的关键不是 TEXT 或 DECIMAL 的具体类型,而是 kind 不能由 skill 和 budget_limit 的空值组合推测。↡记录当前行应恢复为哪一个具体子类的类型字段,例如 engineer 或 manager是恢复对象的唯一入口:kind=engineer 就选择 Engineer 构造器,kind=manager 就选择 Manager 构造器,未知值应该返回可诊断错误。
先预测:如果一行数据的 kind 是 engineer,但 budget_limit 却有值,下面的映射器应该返回一个看似完整的 Engineer,还是在持久化边界拒绝它?把答案写下来,再用专属图的“注入错误类型”开关检查你的假设。
对象到关系行:鉴别器必须先于构造器
加载流程不能先根据“哪些字段不为空”猜一个类,再把 kind 当作普通属性带过。可靠的顺序是:读取公共字段和鉴别器;按允许值选择构造器;校验该类型允许的字段;最后返回具体对象。这样,数据库行和内存对象之间是一条可测试的双向契约。
type EmployeeRow = {
id: number;
name: string;
kind: "engineer" | "manager";
skill: string | null;
budget_limit: number | null;
};
function hydrate(row: EmployeeRow) {
if (row.kind === "engineer") {
if (!row.skill || row.budget_limit !== null)
throw new Error("invalid engineer row");
return new Engineer(row.id, row.name, row.skill);
}
if (row.budget_limit === null || row.skill !== null)
throw new Error("invalid manager row");
return new Manager(row.id, row.name, row.budget_limit);
}的便利来自同一张表:可以先按公共条件过滤,再按 kind
分组恢复对象。它不是“数据库自动懂继承”;映射器仍然要处理未知类型、缺失字段和数据库中绕过应用写入的坏行。尤其不能把未知
kind
静默当成基类,否则新子类上线时,旧服务可能把新数据当成错误的普通员工继续保存。
专属可视化实验:从对象树走到约束边界
先看主图的对象树,再播放到鉴别器路由,最后打开“注入错误类型”。观察同一行在三步里没有换表,但它的可接受条件逐步变得更严格。点“重置图示”后,应该回到第一步且错误开关关闭。
第 1 / 3 步 · ① 对象侧:同一 Employee 入口保留 Engineer / Manager 多态
先看对象,再看鉴别器,最后注入错误检查字段约束。
三个阶段快照
下面的 Stepper 把主图拆成三张确定性的阶段证据。每一步都带有与正文对应的 Diagram,适合在暂停状态下逐项核对。
1. 对象侧:保留多态结构
Employee 是调用方看到的稳定入口;Engineer 和 Manager 只扩展自己的字段。此时还没有决定数据库采用哪一种继承映射。
表级约束:把 NULL 变成可检查的规则
仅有 kind IN (...) 还不够。每一个允许的鉴别器值都应该有自己的字段不变量。例如 Engineer 必须有 skill、不能有 budget_limit;Manager 必须有 budget_limit、不能有 skill。如果数据库支持 CHECK,把规则写进表;如果规则需要跨行或涉及复杂迁移,则在应用校验之外增加约束触发器或写入服务,并为两者保留同一组失败测试。
ALTER TABLE employees
ADD CONSTRAINT engineer_columns_match
CHECK (kind <> 'engineer' OR (skill IS NOT NULL AND budget_limit IS NULL));
ALTER TABLE employees
ADD CONSTRAINT manager_columns_match
CHECK (kind <> 'manager' OR (skill IS NULL AND budget_limit IS NOT NULL));约束的价值在于阻止“应用正常、数据已坏”的分裂状态:后台脚本、旧版本服务和手工 SQL 都可能绕过映射器。加载时仍要再次验证,因为迁移期间可能存在暂时状态,且错误信息需要回到具体行和具体字段。读取失败应停在映射边界,不能返回一个子类字段不完整的对象让业务层自行猜测。
宽表与演进:什么时候这张表开始反对你
↡继承层次中公共字段与各子类字段如何映射到关系表的整体方案,例如单表、类表或具体表继承应该由稳定的查询和变化边界决定,而不是由 ORM 的默认配置决定。单表继承适合这些条件:子类数量有限,公共字段占主要宽度,经常需要查询整个员工集合,多态读取比子类字段独立查询更重要。
风险可以用一张评审卡记录,而不必假装有一个跨系统通用的阈值:登记列总数、每类的非空率、最常用的过滤字段、一次新增子类需要改动的约束和索引数量。若新增一个子类需要触碰所有旧服务的反序列化逻辑,或一行的可空列已经难以解释,就把“迁移到类表继承”列为明确的回退方案。
新增子类时,先扩展允许的鉴别器值,再以兼容顺序部署读写代码和约束;回滚时必须保证旧服务遇到新类型会明确拒绝,而不是把它当成旧类。下面的迁移只展示顺序,不绑定某个 ORM:
-- 1. 先加列与索引,旧数据仍能被旧服务读取
ALTER TABLE employees ADD COLUMN hourly_rate DECIMAL(10, 2);
-- 2. 部署能识别 contractor 的读写代码后,再开放该 kind
ALTER TABLE employees
DROP CONSTRAINT employees_kind_check;
ALTER TABLE employees
ADD CONSTRAINT employees_kind_check
CHECK (kind IN ('engineer', 'manager', 'contractor'));
-- 3. 最后补 contractor 的字段约束与回滚记录
ALTER TABLE employees
ADD CONSTRAINT contractor_columns_match
CHECK (kind <> 'contractor' OR hourly_rate IS NOT NULL);这段顺序揭示了单表继承的边界:加一个类型就要扩大允许值、增加列、更新恢复逻辑、调整约束,并安排旧服务的拒绝行为。如果子类变化频繁,类表继承把字段隔离在各自表中,具体表继承则把每个具体类的完整行分开;两者可能用 JOIN 或 UNION 换取更清晰的列语义。
选择与拒绝矩阵
| 评审问题 | 选择单表继承的证据 | 应改用其他继承策略的信号 |
|---|---|---|
| 查询 | 经常读取整个员工集合,多态查询不想 JOIN | 只有某一子类的字段被高频筛选或统计 |
| 字段变化 | 子类数量少,字段集合相对稳定 | 新子类持续增加,表宽度与索引维护失控 |
| 约束 | 每种 kind 的字段规则可以用 CHECK 表达 | 子类不变量复杂、跨表或需要独立生命周期 |
| 空值 | NULL 只是其他类型的空位,含义可由 kind 解释 | NULL 组合无法解释,报表把空值当成业务状态 |
| 迁移 | 新类型可以兼容部署并保留旧服务拒绝路径 | 每次迁移都必须同步改动许多不可同时发布的服务 |
矩阵的用法是先找拒绝信号,再计算 JOIN 是否真的值得省掉。单表继承不是“性能更快”的承诺:它减少了某类查询的联接,却把稀疏列、约束和部署协调成本集中到一个表。评审记录至少要保留一条反例,以及从单表退出时的回退路径。
常见误区
本章小结
单表继承把一棵对象继承树压到一张关系表中,kind 鉴别器决定每行恢复为何种子类。它的优势是公共查询和多态读取路径简单,代价是子类字段共存造成的可空列、表级约束和演进协调。选择它之前,必须能用失败样本证明三件事:未知类型会拒绝,类型字段与子类字段不会冲突,新增子类有兼容部署与回退路径。
本章练习
练习
问题 1: 一行数据为 kind=manager、skill=NULL、budget_limit=NULL。映射器应如何处理?至少指出一条数据库约束。
问题 2: 产品要求“列出所有员工”与“按员工类型统计数量”,但很少只查询 budget_limit。为什么单表继承可能合适?还要补哪项证据?
问题 3: 新增 Contractor 需要 hourly_rate,旧服务只认识 Engineer 和 Manager。请写出安全发布顺序,并说明回滚时旧服务应该怎样表现。
前后导航
来源与改写范围
- Martin Fowler 作者图书页:核对全书主题与模式参考范围。
- Martin Fowler 企业应用架构模式目录:核对 Single Table Inheritance 的公开模式摘要。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。
本章不复现原书正文、插图或代码;上述资料只限定学习范围,员工继承案例、SQL/TypeScript 片段、评审矩阵、练习、答案与 SVG 图均为本课程独立重写。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 单表继承
把父类和所有子类的字段放进同一张表,再用一个类型字段决定每行应恢复成哪种对象。
- 鉴别器列
记录具体类型的列,例如
engineer或manager;映射器先看它,再选择构造器。- 多态查询
用一个公共入口查询多个子类的对象集合,返回时仍能保留每个对象的具体类型。
- 继承策略
决定对象继承层次如何落到关系表的整体方案,例如单表、类表或具体表继承。