跳转到内容

提炼超类

提炼超类(Extract Superclass)给两个做着类似事情的类建一个共同的父类,把它们相同的字段和方法搬上去。 它解决的痛是:两个类各自长出了一样的名字字段、一样的计算公式,改规则要改两处,而处理它们的代码只能写成“甲或乙”,没法一视同仁。

小冯在做公司的年度预算表。预算表上既要列员工,也要列部门,每一行都是“名字、年成本”。员工类和部门类是两个人在不同时期写的,互相没有关系,但都有 name,都有“月成本乘以 12”的年成本算法,只是部门的月成本叫 total_monthly_cost,是把下属员工的月成本加起来。

"""提炼超类:重构前。"""
class Employee:
def __init__(self, name: str, monthly_cost: int) -> None:
self.name = name
self._monthly_cost = monthly_cost
@property
def monthly_cost(self) -> int:
return self._monthly_cost
def annual_cost(self) -> int:
return self.monthly_cost * 12
class Department:
def __init__(self, name: str, staff: list[Employee]) -> None:
self.name = name
self.staff = staff
@property
def total_monthly_cost(self) -> int:
return sum(e.monthly_cost for e in self.staff)
def annual_cost(self) -> int:
return self.total_monthly_cost * 12
def budget_lines(items: list[Employee | Department]) -> list[str]:
return [f"{x.name} 年成本 {x.annual_cost()}" for x in items]
if __name__ == "__main__":
zhang, li = Employee("张伟", 30000), Employee("李娜", 20000)
for line in budget_lines([zhang, li, Department("研发部", [zhang, li])]):
print(line)
# 输出:
# 张伟 年成本 360000
# 李娜 年成本 240000
# 研发部 年成本 600000
// 提炼超类:重构前。
export class Employee {
constructor(
readonly name: string,
private readonly cost: number,
) {}
get monthlyCost(): number {
return this.cost;
}
annualCost(): number {
return this.monthlyCost * 12;
}
}
export class Department {
constructor(
readonly name: string,
readonly staff: Employee[],
) {}
get totalMonthlyCost(): number {
return this.staff.reduce((sum, e) => sum + e.monthlyCost, 0);
}
annualCost(): number {
return this.totalMonthlyCost * 12;
}
}
export function budgetLines(items: (Employee | Department)[]): string[] {
return items.map((x) => `${x.name} 年成本 ${x.annualCost()}`);
}
const zhang = new Employee("张伟", 30000);
const li = new Employee("李娜", 20000);
for (const line of budgetLines([zhang, li, new Department("研发部", [zhang, li])])) {
console.log(line);
}
// 输出:
// 张伟 年成本 360000
// 李娜 年成本 240000
// 研发部 年成本 600000
import unittest
from target import Department, Employee, budget_lines
class BudgetTest(unittest.TestCase):
def test_annual_cost(self):
zhang, li = Employee("张伟", 30000), Employee("李娜", 20000)
self.assertEqual(zhang.annual_cost(), 360000)
self.assertEqual(Department("研发部", [zhang, li]).annual_cost(), 600000)
def test_budget_lines(self):
zhang = Employee("张伟", 30000)
lines = budget_lines([zhang, Department("测试部", [])])
self.assertEqual(lines, ["张伟 年成本 360000", "测试部 年成本 0"])
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { Department, Employee, budgetLines } from "./target";
test("年成本", () => {
const zhang = new Employee("张伟", 30000);
const li = new Employee("李娜", 20000);
assert.equal(zhang.annualCost(), 360000);
assert.equal(new Department("研发部", [zhang, li]).annualCost(), 600000);
});
test("预算表", () => {
const lines = budgetLines([new Employee("张伟", 30000), new Department("测试部", [])]);
assert.deepEqual(lines, ["张伟 年成本 360000", "测试部 年成本 0"]);
});

提炼前:Employee 和 Department 毫无关系,却都有 name 和 annual_cost,月成本的名字不同

如果现在财务说年成本要按 13 个月算(多一个月的年终奖),小冯要改两处:Employee.annual_cost 和 Department.annual_cost。两处写法不完全一样,一处乘的是 monthly_cost,一处乘的是 total_monthly_cost,搜索 * 12 能找到它们,但没人能保证系统里没有第三处。

如果再加一种要上预算表的东西,比如外包项目,budget_lines 的参数类型就要从“员工或部门”扩成“员工或部门或项目”。所有接收这类对象的函数都在重复这份清单。

更根本的是,代码没有说出一个事实:员工和部门在预算这件事上是同一类东西,都是“一个有名字、有成本的参与方”。这个概念只存在于小冯的脑子里,写在代码里的是两个毫不相干的类。

用第 2 章的名字说,这是重复代码;两个类做着类似的事、接口却不一样(monthly_cost 和 total_monthly_cost),也带着异曲同工的类的味道。

"""提炼超类:重构后。"""
from abc import ABC, abstractmethod
class Party(ABC):
def __init__(self, name: str) -> None:
self.name = name
@property
@abstractmethod
def monthly_cost(self) -> int: ...
def annual_cost(self) -> int:
return self.monthly_cost * 12
class Employee(Party):
def __init__(self, name: str, monthly_cost: int) -> None:
super().__init__(name)
self._monthly_cost = monthly_cost
@property
def monthly_cost(self) -> int:
return self._monthly_cost
class Department(Party):
def __init__(self, name: str, staff: list[Employee]) -> None:
super().__init__(name)
self.staff = staff
@property
def monthly_cost(self) -> int:
return sum(e.monthly_cost for e in self.staff)
def budget_lines(items: list[Party]) -> list[str]:
return [f"{x.name} 年成本 {x.annual_cost()}" for x in items]
if __name__ == "__main__":
zhang, li = Employee("张伟", 30000), Employee("李娜", 20000)
for line in budget_lines([zhang, li, Department("研发部", [zhang, li])]):
print(line)
# 输出:
# 张伟 年成本 360000
# 李娜 年成本 240000
# 研发部 年成本 600000
// 提炼超类:重构后。
export abstract class Party {
constructor(readonly name: string) {}
abstract get monthlyCost(): number;
annualCost(): number {
return this.monthlyCost * 12;
}
}
export class Employee extends Party {
constructor(
name: string,
private readonly cost: number,
) {
super(name);
}
get monthlyCost(): number {
return this.cost;
}
}
export class Department extends Party {
constructor(
name: string,
readonly staff: Employee[],
) {
super(name);
}
get monthlyCost(): number {
return this.staff.reduce((sum, e) => sum + e.monthlyCost, 0);
}
}
export function budgetLines(items: Party[]): string[] {
return items.map((x) => `${x.name} 年成本 ${x.annualCost()}`);
}
const zhang = new Employee("张伟", 30000);
const li = new Employee("李娜", 20000);
for (const line of budgetLines([zhang, li, new Department("研发部", [zhang, li])])) {
console.log(line);
}
// 输出:
// 张伟 年成本 360000
// 李娜 年成本 240000
// 研发部 年成本 600000

提炼后:Party 持有 name 和 annual_cost,Employee 和 Department 各自给出 monthly_cost

年成本改成 13 个月,只改 Party.annual_cost 一处。外包项目只要继承 Party、给出自己的 monthly_cost,就能直接放进 budget_lines,函数签名不用动。“参与方”这个概念有了名字,读代码的人一眼就能看出员工和部门在预算上是同一类东西。

  1. 建一个空的父类 Party,让 Employee 和 Department 都继承它。跑测试。如果两个类的构造函数参数顺序不同,先用改变函数声明(第 6 章)对齐。
  2. 用构造函数本体上移和字段上移(第 51 章)把 name 搬到 Party。跑测试。
  3. 找出名字不同、意思相同的方法:Department.total_monthly_cost 和 Employee.monthly_cost 都是“月成本”。用改变函数声明把它们改成同一个名字 monthly_cost(IDE 可自动完成)。跑测试。
  4. 名字统一之后,两个 annual_cost 变得一模一样,用函数上移(第 51 章)搬到 Party。父类要调用 monthly_cost,就在父类里把它声明成抽象的。跑测试。
  5. 检查使用这两个类的地方,把“甲或乙”的参数类型换成 Party。

第 3 步是这个手法里最需要判断力的一步:名字不同的两个方法是不是同一个意思?total_monthly_cost 和 monthly_cost 是,因为预算表把它们当成同一列。如果两个方法只是名字像、意思不同,统一名字就是在撒谎。

提炼超类:为两个或多个有共同特性的类建一个父类,把共同的字段和方法上移到父类。

它最常出现在系统演化的中期:两个类是独立写出来的,过了一段时间才发现它们在做类似的事。信号有三个:几个字段名字相同、含义相同;有几个方法算法相同,只是用的数据来源不同;外部代码总是用“甲或乙”的类型同时处理它们。

另一个选择是提炼类(第 17 章):把共同的部分提成一个独立的类,两个类各持有一个它的实例。两者的分界是“是不是”和“有没有”:员工“是”一个预算参与方,用继承;员工“有”一个电话号码,用组合。拿不准的时候先用提炼超类,它更简单;以后发现继承用得别扭,还可以用以委托取代超类(第 56 章)换成组合。

重构后的 Party.annual_cost 是一个模板方法(Template Method):它定下了“月成本乘以 12”这个计算框架,框架里需要的 monthly_cost 由子类提供。Employee 从字段里读,Department 把下属员工加起来,父类不关心是怎么得到的。annual_cost 是模板方法,monthly_cost 是基本操作。模板方法见《设计模式》书第 20 章。

Django 项目里,文章、评论、订单常常都有 created_at、updated_at 两个字段和一个“保存时刷新更新时间”的方法。Django 的做法是提炼一个抽象模型(class Meta: abstract = True),各个模型继承它。下面用普通的类模拟,时间用整数代替。

"""模拟 Django 的抽象模型:把两个模型共有的时间戳提炼到父类。"""
CLOCK = [100]
def now() -> int:
CLOCK[0] += 1
return CLOCK[0]
# 提炼前:两个模型各写一遍时间戳。
class ArticleV1:
def __init__(self, title: str) -> None:
self.title = title
self.created_at = self.updated_at = now()
def save(self) -> None:
self.updated_at = now()
class CommentV1:
def __init__(self, text: str) -> None:
self.text = text
self.created_at = self.updated_at = now()
def save(self) -> None:
self.updated_at = now()
# 提炼后:时间戳住在 TimestampedModel 里。
class TimestampedModel:
def __init__(self) -> None:
self.created_at = self.updated_at = now()
def save(self) -> None:
self.updated_at = now()
class Article(TimestampedModel):
def __init__(self, title: str) -> None:
super().__init__()
self.title = title
class Comment(TimestampedModel):
def __init__(self, text: str) -> None:
super().__init__()
self.text = text
if __name__ == "__main__":
for cls, arg in ((ArticleV1, "重构入门"), (Article, "重构入门"), (CommentV1, "写得好"), (Comment, "写得好")):
model = cls(arg)
model.save()
print(cls.__name__, model.updated_at - model.created_at)
# 输出:
# ArticleV1 1
# Article 1
# CommentV1 1
# Comment 1

提炼之后,要给所有模型加一个“软删除”标记,加在 TimestampedModel 里一处即可。真实的 Django 抽象模型不会建自己的数据表,只把字段“复制”给子类,这正好对应“父类只是共同特性的容器”。

两个类只是碰巧有几个同名字段,业务上并不是同一类东西时,不要提炼超类。Employee 和 Product 都有 name 和 price(一个是薪资,一个是售价),硬提一个 NamedPricedThing 出来,以后改员工薪资的计算规则就会碰到商品。共同的父类意味着共同的演化,两个类会因为父类的修改而一起变化,不该一起变的就别放进一个继承体系。

另一个代价是继承只有一次机会。Python 虽然支持多重继承,但用来表达业务概念时很快就会乱;TS 只能继承一个类。如果 Employee 将来还要按全职、兼职分子类,或者要继承某个框架的基类,那么现在把“参与方”这个位置占掉,就要想清楚。已经知道会有第二个继承维度时,一开始就用提炼类做成组合更合适。

  • 两个类有相同的字段、相同算法的方法,外部代码总是写“甲或乙”,就该提炼超类。
  • 先统一名字再上移:名字不同、意思相同的方法用改变函数声明对齐,之后上移就是机械操作。
  • 父类定框架、子类给数据,提炼超类常常顺带得到一个模板方法。
  • “是”用继承,“有”用组合;业务上不会一起演化的类不要放进同一个继承体系。