跳转到内容

移除子类

移除子类(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);
});

移除前:Male 和 Female 各自只覆盖了 gender_code,返回一个常量

如果现在要加“其他”这个新取值,按现在的结构就要再建一个子类,再在 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

移除后:只剩一个 Person,gender_code 是字段,is_male 由它算出

加一种取值,现在只是在 create_person 里允许一个新字母,不用建类。性别只由 gender_code 一个字段表达,count_males 改成问 p.is_male,“类型说一套、字段说一套”的可能没有了。读代码的人看到一个类、一个字段,得到的印象和实际的复杂度一致。

  1. 如果还没有工厂函数,先用以工厂函数取代构造函数(第 48 章)把创建子类的地方收进一个函数。本例已经有 create_person。
  2. 找出所有对子类做类型判断的地方,比如 isinstance(p, Male),先用提炼函数(第 4 章)把它提成父类上的 is_male,调用方改成问 p.is_male。此时 is_male 的实现仍是 isinstance(self, Male)。跑测试。
  3. 在父类上新建一个字段 gender_code,由构造函数接收。父类的 gender_code 取值函数改成返回这个字段。
  4. 改工厂函数:不再按代码选子类,而是把代码传给父类构造函数。跑测试。
  5. 把 is_male 改成比较字段 gender_code == "M",不再依赖子类。跑测试。
  6. 删掉子类。跑测试。IDE 的 Safe Delete 会先检查还有没有引用(IDE 可自动完成)。

第 2 步最容易被跳过,也最重要:只要还有一处 isinstance(p, Male),删子类时就会出错;更糟的是,如果先改了工厂函数,那一处判断不会报错,只是永远返回假,数字悄悄变成 0。

移除子类:子类之间的差别已经不足以支撑一个类时,用父类上的字段表达这个差别,删掉子类。

它常出现在两种情况。一是设计时预留的扩展点从未被用上,子类一直只有一个覆盖常量的方法,本章就是这样。二是曾经有过的行为被搬走了:子类里原本有几个方法,后来用以委托取代子类(第 56 章)或别的重构搬了出去,只剩一层空壳。

它是第 52 章以子类取代类型码的反向手法。两个方向之间的分界线是“子类里有没有行为”:每种类型有自己的计算规则、有自己才有的字段,子类划算;每种类型只是一个不同的值,字段就够了。需求可能在两边来回走:今天会员性别只是一个字段,哪天要按性别做不同的体检套餐,就再用第 52 章把子类建回来,那时候子类里会有真正的方法。

很多 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。

子类虽然只覆盖一个常量,但调用方确实在按类型做事时,先别急着删。上面的错误类就是例子:如果框架的错误处理中间件靠 instanceof 把 NotFoundError 渲染成 404 页面、把 ValidationError 渲染成表单错误,那这些子类承担的是“可以被精确捕获”的职责,Python 的异常层次更是如此,except FileNotFoundError 和 except OSError 的区别就靠子类。判断标准是:去掉子类之后,调用方是不是要写一串 if e.status == ... 来代替原来清晰的 except 分支?如果是,子类在干活。

另一种情况是行为差异即将到来。如果下个迭代就要给不同类型加不同的规则,现在删掉子类、下周再建回来,是白费两次功夫。重构跟着需求走,而不是跟着“现在看起来多余”走。

手法 它关心什么 移除子类关心什么
折叠继承体系(第 55 章) 父类和子类合并成一个类,通常只有一个子类 几个子类都换成父类上的一个字段,父类留下
以子类取代类型码(第 52 章) 字段驱动了太多分支,换成子类 反向:子类只剩常量,换回字段
  • 子类只覆盖一个返回常量的方法时,它是冗赘的元素,用字段加工厂函数代替。
  • 删子类前先把调用方的 isinstance 判断收成父类上的方法,否则改完工厂函数后这些判断会悄悄永远返回假。
  • 有没有行为是分界线:有各自的计算规则用子类,只有不同的值用字段。
  • 子类被用来精确捕获、精确分发时(比如异常层次),它在承担职责,别删。