移除子类
移除子类(Remove Subclass)把差别已经小到只剩一个常量的子类去掉,换成父类上的一个字段,再用工厂函数按数据创建对象。 它解决的痛是:为了一个返回固定值的方法维护一整个子类,读代码的人要在几个几乎空的类之间跳来跳去,加一种取值还得再建一个类。
小陈维护一个会员系统。几年前有人设计了 Person 和两个子类 Male、Female,当时设想以后不同性别会有不同的业务规则。几年过去,那些规则一条也没出现,两个子类里各自只有一个返回性别代码的方法。“不愿透露”已经用一个返回 "X" 的分支凑合着支持了,现在产品又要求加一个“其他”选项,小陈得决定是再建一个子类,还是换个做法。
"""移除子类:重构前。"""
class Person: def __init__(self, name: str) -> None: self.name = name
@property def gender_code(self) -> str: return "X"
class Male(Person): @property def gender_code(self) -> str: return "M"
class Female(Person): @property def gender_code(self) -> str: return "F"
def create_person(name: str, code: str) -> Person: match code: case "M": return Male(name) case "F": return Female(name) case _: return Person(name)
def count_males(people: list[Person]) -> int: return sum(1 for p in people if isinstance(p, Male))
if __name__ == "__main__": people = [create_person("张伟", "M"), create_person("李娜", "F"), create_person("小周", "X")] print(" ".join(p.gender_code for p in people)) print(count_males(people))
# 输出:# M F X# 1// 移除子类:重构前。export class Person { constructor(readonly name: string) {}
get genderCode(): string { return "X"; }}
export class Male extends Person { get genderCode(): string { return "M"; }}
export class Female extends Person { get genderCode(): string { return "F"; }}
export function createPerson(name: string, code: string): Person { switch (code) { case "M": return new Male(name); case "F": return new Female(name); default: return new Person(name); }}
export function countMales(people: Person[]): number { return people.filter((p) => p instanceof Male).length;}
const people = [createPerson("张伟", "M"), createPerson("李娜", "F"), createPerson("小周", "X")];console.log(people.map((p) => p.genderCode).join(" "));console.log(countMales(people));
// 输出:// M F X// 1测试只通过 create_person 和 count_males 使用这些类,不直接碰子类:
import unittest
from target import count_males, create_person
class PersonTest(unittest.TestCase): def test_gender_codes(self): codes = [create_person("某人", c).gender_code for c in ("M", "F", "X", "?")] self.assertEqual(codes, ["M", "F", "X", "X"])
def test_count_males(self): people = [create_person("张伟", "M"), create_person("李娜", "F"), create_person("王强", "M")] self.assertEqual(count_males(people), 2)
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { countMales, createPerson } from "./target";
test("性别代码", () => { assert.deepEqual(["M", "F", "X", "?"].map((c) => createPerson("某人", c).genderCode), ["M", "F", "X", "X"]);});
test("统计男性人数", () => { const people = [createPerson("张伟", "M"), createPerson("李娜", "F"), createPerson("王强", "M")]; assert.equal(countMales(people), 2);});如果现在要加“其他”这个新取值,按现在的结构就要再建一个子类,再在 create_person 里加一个分支。一个只为返回字母 "O" 而存在的类,要占十来行代码,还要有人去理解它为什么存在。
更隐蔽的成本在 count_males 这样的地方:它靠 isinstance(p, Male) 判断性别。类型判断散在调用方,意味着“男性”这个概念同时由类的身份和 gender_code 的值两处表达,迟早会对不上。比如数据迁移脚本为了省事,绕过 create_person 直接构造了基类 Person,这个对象即使代表男性,也永远不会被 count_males 数到。
读代码的人还会被误导。看到继承体系,正常的推断是“男性和女性在业务上有不同的行为”,于是去找那些行为,翻遍代码发现只有一个常量。子类在暗示一种并不存在的复杂度。
用第 2 章的名字说,Male 和 Female 是冗赘的元素:它们曾经被预期承担职责,现在只是一层壳。
"""移除子类:重构后。"""
class Person: def __init__(self, name: str, gender_code: str = "X") -> None: self.name = name self.gender_code = gender_code
@property def is_male(self) -> bool: return self.gender_code == "M"
def create_person(name: str, code: str) -> Person: return Person(name, code if code in ("M", "F") else "X")
def count_males(people: list[Person]) -> int: return sum(1 for p in people if p.is_male)
if __name__ == "__main__": people = [create_person("张伟", "M"), create_person("李娜", "F"), create_person("小周", "X")] print(" ".join(p.gender_code for p in people)) print(count_males(people))
# 输出:# M F X# 1// 移除子类:重构后。export class Person { constructor( readonly name: string, readonly genderCode: string = "X", ) {}
get isMale(): boolean { return this.genderCode === "M"; }}
export function createPerson(name: string, code: string): Person { return new Person(name, code === "M" || code === "F" ? code : "X");}
export function countMales(people: Person[]): number { return people.filter((p) => p.isMale).length;}
const people = [createPerson("张伟", "M"), createPerson("李娜", "F"), createPerson("小周", "X")];console.log(people.map((p) => p.genderCode).join(" "));console.log(countMales(people));
// 输出:// M F X// 1加一种取值,现在只是在 create_person 里允许一个新字母,不用建类。性别只由 gender_code 一个字段表达,count_males 改成问 p.is_male,“类型说一套、字段说一套”的可能没有了。读代码的人看到一个类、一个字段,得到的印象和实际的复杂度一致。
怎么一步步做
Section titled “怎么一步步做”- 如果还没有工厂函数,先用以工厂函数取代构造函数(第 48 章)把创建子类的地方收进一个函数。本例已经有
create_person。 - 找出所有对子类做类型判断的地方,比如
isinstance(p, Male),先用提炼函数(第 4 章)把它提成父类上的is_male,调用方改成问p.is_male。此时is_male的实现仍是isinstance(self, Male)。跑测试。 - 在父类上新建一个字段
gender_code,由构造函数接收。父类的gender_code取值函数改成返回这个字段。 - 改工厂函数:不再按代码选子类,而是把代码传给父类构造函数。跑测试。
- 把
is_male改成比较字段gender_code == "M",不再依赖子类。跑测试。 - 删掉子类。跑测试。IDE 的 Safe Delete 会先检查还有没有引用(IDE 可自动完成)。
第 2 步最容易被跳过,也最重要:只要还有一处 isinstance(p, Male),删子类时就会出错;更糟的是,如果先改了工厂函数,那一处判断不会报错,只是永远返回假,数字悄悄变成 0。
定义与适用场景
Section titled “定义与适用场景”移除子类:子类之间的差别已经不足以支撑一个类时,用父类上的字段表达这个差别,删掉子类。
它常出现在两种情况。一是设计时预留的扩展点从未被用上,子类一直只有一个覆盖常量的方法,本章就是这样。二是曾经有过的行为被搬走了:子类里原本有几个方法,后来用以委托取代子类(第 56 章)或别的重构搬了出去,只剩一层空壳。
它是第 52 章以子类取代类型码的反向手法。两个方向之间的分界线是“子类里有没有行为”:每种类型有自己的计算规则、有自己才有的字段,子类划算;每种类型只是一个不同的值,字段就够了。需求可能在两边来回走:今天会员性别只是一个字段,哪天要按性别做不同的体检套餐,就再用第 52 章把子类建回来,那时候子类里会有真正的方法。
真实系统里的例子
Section titled “真实系统里的例子”HTTP 错误类
Section titled “HTTP 错误类”很多 Node 项目里有一串错误子类:NotFoundError、ForbiddenError、ConflictError,每个只是带着不同的状态码和默认消息。下面把它们收成一个 HttpError,用一张表和一个工厂函数代替一串类。
// HTTP 错误:用状态码字段取代一串只差常量的子类。
// 移除前:每个状态码一个类。class HttpErrorV1 extends Error { status = 500;}
class NotFoundErrorV1 extends HttpErrorV1 { status = 404; constructor() { super("资源不存在"); }}
class ForbiddenErrorV1 extends HttpErrorV1 { status = 403; constructor() { super("没有权限"); }}
// 移除后:一个类,一张表。const MESSAGES: Record<number, string> = { 403: "没有权限", 404: "资源不存在" };
class HttpError extends Error { constructor( readonly status: number, message: string = MESSAGES[status] ?? "服务器错误", ) { super(message); }}
function httpError(status: number): HttpError { return new HttpError(status);}
const before = [new NotFoundErrorV1(), new ForbiddenErrorV1()];const after = [httpError(404), httpError(403)];console.log(before.map((e) => `${e.status} ${e.message}`).join(";"));console.log(after.map((e) => `${e.status} ${e.message}`).join(";"));
// 输出:// 404 资源不存在;403 没有权限// 404 资源不存在;403 没有权限加一个 409 冲突,只是在 MESSAGES 里加一行。调用方原来写 catch (e) { if (e instanceof NotFoundError) ... } 的地方,改成判断 e.status === 404。
什么时候别用
Section titled “什么时候别用”子类虽然只覆盖一个常量,但调用方确实在按类型做事时,先别急着删。上面的错误类就是例子:如果框架的错误处理中间件靠 instanceof 把 NotFoundError 渲染成 404 页面、把 ValidationError 渲染成表单错误,那这些子类承担的是“可以被精确捕获”的职责,Python 的异常层次更是如此,except FileNotFoundError 和 except OSError 的区别就靠子类。判断标准是:去掉子类之后,调用方是不是要写一串 if e.status == ... 来代替原来清晰的 except 分支?如果是,子类在干活。
另一种情况是行为差异即将到来。如果下个迭代就要给不同类型加不同的规则,现在删掉子类、下周再建回来,是白费两次功夫。重构跟着需求走,而不是跟着“现在看起来多余”走。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 移除子类关心什么 |
|---|---|---|
| 折叠继承体系(第 55 章) | 父类和子类合并成一个类,通常只有一个子类 | 几个子类都换成父类上的一个字段,父类留下 |
| 以子类取代类型码(第 52 章) | 字段驱动了太多分支,换成子类 | 反向:子类只剩常量,换回字段 |
- 子类只覆盖一个返回常量的方法时,它是冗赘的元素,用字段加工厂函数代替。
- 删子类前先把调用方的
isinstance判断收成父类上的方法,否则改完工厂函数后这些判断会悄悄永远返回假。 - 有没有行为是分界线:有各自的计算规则用子类,只有不同的值用字段。
- 子类被用来精确捕获、精确分发时(比如异常层次),它在承担职责,别删。