提炼类 / 内联类
提炼类(Extract Class)把一个类里总是一起出现、一起变化的一组字段和方法搬到一个新类里,原来的类持有新类的对象;内联类(Inline Class)反过来,把一个没剩多少职责的类并回使用它的类。 它们解决的痛是:一个类越长越大,什么都管,改一处要读懂一大片;或者反过来,一个类薄得只剩一层壳,读代码要多跳一次。
小石维护一个人事系统。Person 类最早只有名字,后来加了办公电话的区号和号码,又加了拼电话号码的方法、判断是否本地号码的方法。现在要支持分机号和国际区号。
"""人员与电话:重构前。"""
class Person: def __init__(self, name: str, area_code: str, number: str) -> None: self.name = name self.office_area_code = area_code self.office_number = number
def telephone(self) -> str: return f"({self.office_area_code}) {self.office_number}"
def is_local(self, city_area_code: str) -> bool: return self.office_area_code == city_area_code
def business_card(self) -> str: return f"{self.name} · 电话 {self.telephone()}"
def make_person(name: str, area_code: str, number: str) -> Person: return Person(name, area_code, number)
if __name__ == "__main__": p = make_person("石磊", "021", "6123 4567") print(p.business_card()) print(p.is_local("021"), p.is_local("010"))
# 输出:# 石磊 · 电话 (021) 6123 4567# True False// 人员与电话:重构前。export class Person { constructor( readonly name: string, public officeAreaCode: string, public officeNumber: string, ) {}
telephone(): string { return `(${this.officeAreaCode}) ${this.officeNumber}`; }
isLocal(cityAreaCode: string): boolean { return this.officeAreaCode === cityAreaCode; }
businessCard(): string { return `${this.name} · 电话 ${this.telephone()}`; }}
export function makePerson(name: string, areaCode: string, number: string): Person { return new Person(name, areaCode, number);}
const p = makePerson("石磊", "021", "6123 4567");console.log(p.businessCard());console.log(p.isLocal("021"), p.isLocal("010"));
// 输出:// 石磊 · 电话 (021) 6123 4567// true false测试通过 make_person 和三个公开方法:
import unittest
from target import make_person
class PersonTest(unittest.TestCase): def test_business_card(self): self.assertEqual(make_person("石磊", "021", "6123 4567").business_card(), "石磊 · 电话 (021) 6123 4567")
def test_telephone(self): self.assertEqual(make_person("王芳", "010", "8765 4321").telephone(), "(010) 8765 4321")
def test_is_local(self): p = make_person("石磊", "021", "6123 4567") self.assertTrue(p.is_local("021")) self.assertFalse(p.is_local("010"))
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { makePerson } from "./target";
test("名片", () => { assert.equal(makePerson("石磊", "021", "6123 4567").businessCard(), "石磊 · 电话 (021) 6123 4567");});
test("电话格式", () => { assert.equal(makePerson("王芳", "010", "8765 4321").telephone(), "(010) 8765 4321");});
test("是否本地号码", () => { const p = makePerson("石磊", "021", "6123 4567"); assert.equal(p.isLocal("021"), true); assert.equal(p.isLocal("010"), false);});如果现在要支持分机号和国际区号,Person 要再加两个字段 office_extension、office_country_code,telephone 要改,is_local 要考虑国际区号。等再加一个手机号,又是一组 mobile_area_code、mobile_number、拼手机号的方法,几乎是把办公电话那一套复制一遍。
信号其实早就有了:四个字段里有两个带着同样的前缀 office_,两个方法只用这两个字段、完全不碰 name。这一组字段和方法总是一起变,它们是一个独立的概念“电话号码”,只是还住在 Person 里。Person 因为人员信息的原因要改,也因为电话格式的原因要改。
用第 2 章的名字说,这是过大的类的早期样子,也是数据泥团:区号和号码总是结伴出现。
"""人员与电话:重构后。"""
class TelephoneNumber: def __init__(self, area_code: str, number: str) -> None: self.area_code = area_code self.number = number
def __str__(self) -> str: return f"({self.area_code}) {self.number}"
def is_local(self, city_area_code: str) -> bool: return self.area_code == city_area_code
class Person: def __init__(self, name: str, office_phone: TelephoneNumber) -> None: self.name = name self.office_phone = office_phone
def telephone(self) -> str: return str(self.office_phone)
def is_local(self, city_area_code: str) -> bool: return self.office_phone.is_local(city_area_code)
def business_card(self) -> str: return f"{self.name} · 电话 {self.telephone()}"
def make_person(name: str, area_code: str, number: str) -> Person: return Person(name, TelephoneNumber(area_code, number))
if __name__ == "__main__": p = make_person("石磊", "021", "6123 4567") print(p.business_card()) print(p.is_local("021"), p.is_local("010"))
# 输出:# 石磊 · 电话 (021) 6123 4567# True False// 人员与电话:重构后。export class TelephoneNumber { constructor( readonly areaCode: string, readonly number: string, ) {}
toString(): string { return `(${this.areaCode}) ${this.number}`; }
isLocal(cityAreaCode: string): boolean { return this.areaCode === cityAreaCode; }}
export class Person { constructor( readonly name: string, readonly officePhone: TelephoneNumber, ) {}
telephone(): string { return this.officePhone.toString(); }
isLocal(cityAreaCode: string): boolean { return this.officePhone.isLocal(cityAreaCode); }
businessCard(): string { return `${this.name} · 电话 ${this.telephone()}`; }}
export function makePerson(name: string, areaCode: string, number: string): Person { return new Person(name, new TelephoneNumber(areaCode, number));}
const p = makePerson("石磊", "021", "6123 4567");console.log(p.businessCard());console.log(p.isLocal("021"), p.isLocal("010"));
// 输出:// 石磊 · 电话 (021) 6123 4567// true false分机号和国际区号现在是 TelephoneNumber 的事,加字段、改 __str__,Person 一行不动。加手机号也不用复制:Person 再持有一个 mobile_phone: TelephoneNumber 就行,格式和判断逻辑直接复用。Person 现在只因为人员信息的原因而改,TelephoneNumber 只因为电话格式的原因而改。
Person 上的 telephone 和 is_local 保留了下来,只是转发。这是为了不改动外部调用方,测试一字未改就是证据。以后如果调用方都改成直接用 person.office_phone,这两个转发方法可以删掉,见第 18 章移除中间人。
怎么确认提炼的方向是对的?一个简单的检验是看新类能不能脱离原类被使用和测试。TelephoneNumber 可以单独构造、单独测试,客户、供应商、门店都可以持有它,说明它是一个独立的概念。如果新类的每个方法都要回头读原类的字段,或者离开原类就没法构造,那它只是被硬切下来的一块,边界画错了。
另一个检验是名字。好的提炼会得到一个业务里本来就有的名词:电话号码、地址、工资单、税额计算器。如果只能起出 PersonHelper、PersonData2 这样的名字,说明这组字段和方法还没有构成一个概念,先别拆,等需求再变一两次,边界通常会自己显出来。
怎么一步步做
Section titled “怎么一步步做”- 想清楚要拆出去的是哪一组字段和方法,给新类起个名字。如果名字很难起,说明这组东西也许不是一个概念。
- 建新类,在原类的构造函数里创建它的实例,存在一个字段上。跑测试。
- 用搬移字段(第 22 章)把字段一个一个搬到新类,原类里对这些字段的访问改成经过新对象。每搬一个跑一次测试。
- 用搬移函数(第 21 章)把只用这些字段的方法搬到新类,原类里留一个转发方法。从最底层、不调用别的方法的那个开始搬。每搬一个跑一次测试。
- 检查两个类的接口,去掉不需要的转发,给剩下的方法改个更合适的名字。
- 决定新类要不要对外公开。如果公开,考虑把它做成值对象(第 33 章),避免别人拿到引用后在外面改。
IDE 能帮上第 3、4 步的机械部分,比如 PyCharm 的 Move、IntelliJ 系列的 Extract Delegate(IDE 可自动完成),但“这一组算不算一个概念”的判断只能你做。
内联类的步骤反过来:在目标类里为被内联类的每个公开方法建一个对应方法,把调用方改成调用目标类,再把被内联类的字段和方法体搬进来,最后删掉它。
定义与适用场景
Section titled “定义与适用场景”提炼类:把一个类里职责相对独立的一组数据和行为搬到一个新类,原类持有新类的实例。判断该不该提炼的几个信号:几个字段有共同的前缀;几个方法只用其中一部分字段;改某一组字段时,另一组完全不受影响;想给类里的一部分写单元测试,却得先构造整个大对象。
它是“组合优于继承”在日常代码里最常见的样子:原类不是通过继承获得电话的能力,而是持有一个电话对象。这个方向越走越远,就会得到一组各管一摊的小类。
内联类是它的反向手法。当一个类的职责被一点点搬走,最后只剩一两个字段和转发方法,它就该被并回去。比如物流系统里的 TrackingInformation,最初有承运商、单号、查询轨迹的方法,后来查询轨迹搬到了专门的服务里,只剩两个字段:
"""内联类:重构前。"""
class TrackingInformation: def __init__(self, company: str, number: str) -> None: self.company = company self.number = number
def display(self) -> str: return f"{self.company}: {self.number}"
class Shipment: def __init__(self, company: str, number: str) -> None: self.tracking = TrackingInformation(company, number)
def tracking_info(self) -> str: return self.tracking.display()
if __name__ == "__main__": print(Shipment("顺丰", "SF1001").tracking_info())
# 输出:# 顺丰: SF1001"""内联类:重构后。"""
class Shipment: def __init__(self, company: str, number: str) -> None: self.shipping_company = company self.tracking_number = number
def tracking_info(self) -> str: return f"{self.shipping_company}: {self.tracking_number}"
if __name__ == "__main__": print(Shipment("顺丰", "SF1001").tracking_info())
# 输出:# 顺丰: SF1001TrackingInformation 已经没有自己的行为,读代码的人看到 shipment.tracking.display() 还得多跳一层,并回去更直接。注意并回来的字段改了名:company 变成 shipping_company,number 变成 tracking_number。在 TrackingInformation 里,“公司”和“号码”有类名做上下文,不会被误解;并进 Shipment 之后,Shipment.number 可能是运单号,也可能是订单号,所以名字要把原来由类名提供的那部分意思补上。内联类时顺手做一次字段改名(第 31 章),是常见的收尾。另一个内联类的场合是重新划分职责:两个类的边界画错了,先把它们合成一个,再按新的理解重新提炼。
在老系统里做提炼类,最常碰到的阻力是数据库:person 表上已经有 office_area_code 和 office_number 两列。代码里的类可以先提炼出来,表结构不动,Person 从两列读出数据后构造一个 TelephoneNumber,保存时再拆回两列。等代码里所有地方都通过 TelephoneNumber 访问电话之后,再决定要不要把电话拆成单独的表。代码的结构和存储的结构可以分开演进,不必绑在同一次改动里。
真实系统里的例子
Section titled “真实系统里的例子”从用户类里提炼地址
Section titled “从用户类里提炼地址”几乎每个系统的 User 类都会长出一串 address_city、address_street、address_zip,外加格式化和校验地址的方法。下面把它们提炼成 Address,User 持有一个地址对象,邮寄标签和账单都直接复用。
"""从 User 里提炼 Address。"""
from dataclasses import dataclass
@dataclass(frozen=True)class Address: province: str city: str street: str zip_code: str
def __post_init__(self) -> None: if not (len(self.zip_code) == 6 and self.zip_code.isdigit()): raise ValueError(f"邮编应为 6 位数字:{self.zip_code}")
def label(self) -> str: return f"{self.province}{self.city}{self.street}({self.zip_code})"
@dataclassclass User: name: str shipping: Address billing: Address
if __name__ == "__main__": home = Address("上海市", "浦东新区", "世纪大道 100 号", "200120") user = User("石磊", shipping=home, billing=home) print(user.shipping.label()) print(user.shipping == user.billing) try: Address("北京市", "海淀区", "某路 1 号", "1000") except ValueError as error: print(error)
# 输出:# 上海市浦东新区世纪大道 100 号(200120)# True# 邮编应为 6 位数字:1000提炼之后,“收货地址”和“账单地址”是两个 Address,不用在 User 上再复制一套 billing_city、billing_street。邮编校验也只写一处。
发票组件里的税额计算
Section titled “发票组件里的税额计算”前端的发票组件常常一边负责显示,一边算税:税率表、四舍五入规则、免税品判断,全写在组件里。把计算提炼成 TaxCalculator,组件只负责显示,计算可以单独测,后端生成 PDF 的脚本也能复用同一个类。
// 从发票组件里提炼 TaxCalculator。interface Line { name: string; amount: number; category: "food" | "book" | "general";}
class TaxCalculator { constructor(private readonly rates: Record<Line["category"], number> = { food: 0.09, book: 0, general: 0.13 }) {}
taxFor(line: Line): number { return Math.round(line.amount * this.rates[line.category] * 100) / 100; }
total(lines: Line[]): number { return lines.reduce((sum, line) => sum + this.taxFor(line), 0); }}
class InvoiceView { constructor(private readonly tax = new TaxCalculator()) {}
render(lines: Line[]): string[] { return [...lines.map((l) => `${l.name} ${l.amount} 税 ${this.tax.taxFor(l)}`), `合计税额 ${this.tax.total(lines)}`]; }}
const lines: Line[] = [ { name: "咖啡豆", amount: 100, category: "food" }, { name: "重构", amount: 99, category: "book" }, { name: "手冲壶", amount: 200, category: "general" },];console.log(new InvoiceView().render(lines).join("\n"));
// 输出:// 咖啡豆 100 税 9// 重构 99 税 0// 手冲壶 200 税 26// 合计税额 35InvoiceView 的构造参数默认创建一个 TaxCalculator,税率表又是 TaxCalculator 的构造参数,测试或者别的地区可以传进一个按别的税率算的实例。这是提炼类顺带得到的好处:拆出去的部分可以被替换。
提炼出来的类要不要对外公开,也值得想一想。如果调用方需要直接拿到电话号码去做别的事(比如拨号、发短信),就公开它,最好做成不可变的值对象;如果它只是原类内部的实现细节,就让原类保留转发方法,外部不知道它的存在,以后要换实现也不影响谁。公开得越多,将来改动的牵连就越大。
什么时候别用
Section titled “什么时候别用”两三个字段、一两个方法的类,不要为了“单一职责”硬拆,拆出来的类没有自己的规则,只是多了一层。提炼的回报来自“这一组东西会独立变化”,如果它们总和类里别的部分一起改,拆开反而要同时改两个类。
也别一次拆出一个大类。先拆出最明显的一组,跑测试、提交,再看剩下的部分是否还需要拆。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 提炼类关心什么 |
|---|---|---|
| 提炼超类(第 54 章) | 两个类的共同部分,用继承共享 | 一个类里的一部分,用持有(组合)拆出 |
| 引入参数对象(第 9 章) | 函数之间传来传去的一组参数 | 类内部的一组字段 |
| 以对象取代基本类型(第 15 章) | 一个字段带着规则 | 一组字段带着规则;提炼出的小类常常也是值对象 |
Employee 类里有一组工资相关的字段和方法:月薪、奖金比例、算年薪、算个税。把它们提炼成 Payroll 类,Employee 持有一个 Payroll。Employee 对外的方法和输出不变。
"""练习:员工工资。"""
class Employee: def __init__(self, name: str, monthly_salary: float, bonus_rate: float) -> None: self.name = name self.monthly_salary = monthly_salary self.bonus_rate = bonus_rate
def annual_income(self) -> float: return self.monthly_salary * 12 * (1 + self.bonus_rate)
def income_tax(self) -> float: taxable = max(0.0, self.annual_income() - 60000) return round(taxable * 0.1, 2)
def summary(self) -> str: return f"{self.name} 年收入 {self.annual_income():.0f},个税 {self.income_tax():.0f}"
def make_employee(name: str, monthly_salary: float, bonus_rate: float) -> Employee: return Employee(name, monthly_salary, bonus_rate)
if __name__ == "__main__": print(make_employee("王芳", 10000, 0.2).summary())
# 输出:# 王芳 年收入 144000,个税 8400import unittest
from target import make_employee
class EmployeeTest(unittest.TestCase): def test_summary(self): self.assertEqual(make_employee("王芳", 10000, 0.2).summary(), "王芳 年收入 144000,个税 8400")
def test_low_income_pays_no_tax(self): self.assertEqual(make_employee("小林", 4000, 0).income_tax(), 0.0)
def test_annual_income(self): self.assertEqual(make_employee("王芳", 10000, 0.5).annual_income(), 180000)
if __name__ == "__main__": unittest.main()// 练习:员工工资。export class Employee { constructor( readonly name: string, public monthlySalary: number, public bonusRate: number, ) {}
annualIncome(): number { return this.monthlySalary * 12 * (1 + this.bonusRate); }
incomeTax(): number { const taxable = Math.max(0, this.annualIncome() - 60000); return Math.round(taxable * 0.1 * 100) / 100; }
summary(): string { return `${this.name} 年收入 ${this.annualIncome().toFixed(0)},个税 ${this.incomeTax().toFixed(0)}`; }}
export function makeEmployee(name: string, monthlySalary: number, bonusRate: number): Employee { return new Employee(name, monthlySalary, bonusRate);}
console.log(makeEmployee("王芳", 10000, 0.2).summary());
// 输出:// 王芳 年收入 144000,个税 8400import test from "node:test";import assert from "node:assert/strict";import { makeEmployee } from "./target";
test("摘要", () => { assert.equal(makeEmployee("王芳", 10000, 0.2).summary(), "王芳 年收入 144000,个税 8400");});
test("低收入不交税", () => { assert.equal(makeEmployee("小林", 4000, 0).incomeTax(), 0);});
test("年收入", () => { assert.equal(makeEmployee("王芳", 10000, 0.5).annualIncome(), 180000);});- 几个字段有共同前缀、几个方法只用其中一部分字段,就是一个还没独立出来的类。
- 提炼时先建新类、再一个个搬字段、再搬方法,原类留下转发,测试不用改。
- 提炼出的小类常常值得做成不可变的值对象。
- 内联类是反向手法:一个类职责被搬空,只剩一层壳时并回去。
- 提炼类是“组合优于继承”最常见的日常形态。