提炼超类
提炼超类(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// 研发部 年成本 600000import 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"]);});如果现在财务说年成本要按 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年成本改成 13 个月,只改 Party.annual_cost 一处。外包项目只要继承 Party、给出自己的 monthly_cost,就能直接放进 budget_lines,函数签名不用动。“参与方”这个概念有了名字,读代码的人一眼就能看出员工和部门在预算上是同一类东西。
怎么一步步做
Section titled “怎么一步步做”- 建一个空的父类
Party,让Employee和Department都继承它。跑测试。如果两个类的构造函数参数顺序不同,先用改变函数声明(第 6 章)对齐。 - 用构造函数本体上移和字段上移(第 51 章)把
name搬到Party。跑测试。 - 找出名字不同、意思相同的方法:
Department.total_monthly_cost和Employee.monthly_cost都是“月成本”。用改变函数声明把它们改成同一个名字monthly_cost(IDE 可自动完成)。跑测试。 - 名字统一之后,两个
annual_cost变得一模一样,用函数上移(第 51 章)搬到Party。父类要调用monthly_cost,就在父类里把它声明成抽象的。跑测试。 - 检查使用这两个类的地方,把“甲或乙”的参数类型换成
Party。
第 3 步是这个手法里最需要判断力的一步:名字不同的两个方法是不是同一个意思?total_monthly_cost 和 monthly_cost 是,因为预算表把它们当成同一列。如果两个方法只是名字像、意思不同,统一名字就是在撒谎。
定义与适用场景
Section titled “定义与适用场景”提炼超类:为两个或多个有共同特性的类建一个父类,把共同的字段和方法上移到父类。
它最常出现在系统演化的中期:两个类是独立写出来的,过了一段时间才发现它们在做类似的事。信号有三个:几个字段名字相同、含义相同;有几个方法算法相同,只是用的数据来源不同;外部代码总是用“甲或乙”的类型同时处理它们。
另一个选择是提炼类(第 17 章):把共同的部分提成一个独立的类,两个类各持有一个它的实例。两者的分界是“是不是”和“有没有”:员工“是”一个预算参与方,用继承;员工“有”一个电话号码,用组合。拿不准的时候先用提炼超类,它更简单;以后发现继承用得别扭,还可以用以委托取代超类(第 56 章)换成组合。
相关的设计模式
Section titled “相关的设计模式”重构后的 Party.annual_cost 是一个模板方法(Template Method):它定下了“月成本乘以 12”这个计算框架,框架里需要的 monthly_cost 由子类提供。Employee 从字段里读,Department 把下属员工加起来,父类不关心是怎么得到的。annual_cost 是模板方法,monthly_cost 是基本操作。模板方法见《设计模式》书第 20 章。
真实系统里的例子
Section titled “真实系统里的例子”Django 的抽象模型
Section titled “Django 的抽象模型”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 抽象模型不会建自己的数据表,只把字段“复制”给子类,这正好对应“父类只是共同特性的容器”。
什么时候别用
Section titled “什么时候别用”两个类只是碰巧有几个同名字段,业务上并不是同一类东西时,不要提炼超类。Employee 和 Product 都有 name 和 price(一个是薪资,一个是售价),硬提一个 NamedPricedThing 出来,以后改员工薪资的计算规则就会碰到商品。共同的父类意味着共同的演化,两个类会因为父类的修改而一起变化,不该一起变的就别放进一个继承体系。
另一个代价是继承只有一次机会。Python 虽然支持多重继承,但用来表达业务概念时很快就会乱;TS 只能继承一个类。如果 Employee 将来还要按全职、兼职分子类,或者要继承某个框架的基类,那么现在把“参与方”这个位置占掉,就要想清楚。已经知道会有第二个继承维度时,一开始就用提炼类做成组合更合适。
- 两个类有相同的字段、相同算法的方法,外部代码总是写“甲或乙”,就该提炼超类。
- 先统一名字再上移:名字不同、意思相同的方法用改变函数声明对齐,之后上移就是机械操作。
- 父类定框架、子类给数据,提炼超类常常顺带得到一个模板方法。
- “是”用继承,“有”用组合;业务上不会一起演化的类不要放进同一个继承体系。