跳转到内容

提炼类 / 内联类

提炼类(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 章的名字说,这是过大的类的早期样子,也是数据泥团:区号和号码总是结伴出现。

提炼前的 Person:四个字段里两个带 office_ 前缀,telephone 和 is_local 只用这两个字段

"""人员与电话:重构后。"""
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

提炼后:Person 持有一个 TelephoneNumber,电话相关的字段和方法都搬了过去,Person 只转发

分机号和国际区号现在是 TelephoneNumber 的事,加字段、改 __str__,Person 一行不动。加手机号也不用复制:Person 再持有一个 mobile_phone: TelephoneNumber 就行,格式和判断逻辑直接复用。Person 现在只因为人员信息的原因而改,TelephoneNumber 只因为电话格式的原因而改。

Person 上的 telephone 和 is_local 保留了下来,只是转发。这是为了不改动外部调用方,测试一字未改就是证据。以后如果调用方都改成直接用 person.office_phone,这两个转发方法可以删掉,见第 18 章移除中间人。

怎么确认提炼的方向是对的?一个简单的检验是看新类能不能脱离原类被使用和测试。TelephoneNumber 可以单独构造、单独测试,客户、供应商、门店都可以持有它,说明它是一个独立的概念。如果新类的每个方法都要回头读原类的字段,或者离开原类就没法构造,那它只是被硬切下来的一块,边界画错了。

另一个检验是名字。好的提炼会得到一个业务里本来就有的名词:电话号码、地址、工资单、税额计算器。如果只能起出 PersonHelper、PersonData2 这样的名字,说明这组字段和方法还没有构成一个概念,先别拆,等需求再变一两次,边界通常会自己显出来。

  1. 想清楚要拆出去的是哪一组字段和方法,给新类起个名字。如果名字很难起,说明这组东西也许不是一个概念。
  2. 建新类,在原类的构造函数里创建它的实例,存在一个字段上。跑测试。
  3. 用搬移字段(第 22 章)把字段一个一个搬到新类,原类里对这些字段的访问改成经过新对象。每搬一个跑一次测试。
  4. 用搬移函数(第 21 章)把只用这些字段的方法搬到新类,原类里留一个转发方法。从最底层、不调用别的方法的那个开始搬。每搬一个跑一次测试。
  5. 检查两个类的接口,去掉不需要的转发,给剩下的方法改个更合适的名字。
  6. 决定新类要不要对外公开。如果公开,考虑把它做成值对象(第 33 章),避免别人拿到引用后在外面改。

IDE 能帮上第 3、4 步的机械部分,比如 PyCharm 的 Move、IntelliJ 系列的 Extract Delegate(IDE 可自动完成),但“这一组算不算一个概念”的判断只能你做。

内联类的步骤反过来:在目标类里为被内联类的每个公开方法建一个对应方法,把调用方改成调用目标类,再把被内联类的字段和方法体搬进来,最后删掉它。

提炼类:把一个类里职责相对独立的一组数据和行为搬到一个新类,原类持有新类的实例。判断该不该提炼的几个信号:几个字段有共同的前缀;几个方法只用其中一部分字段;改某一组字段时,另一组完全不受影响;想给类里的一部分写单元测试,却得先构造整个大对象。

它是“组合优于继承”在日常代码里最常见的样子:原类不是通过继承获得电话的能力,而是持有一个电话对象。这个方向越走越远,就会得到一组各管一摊的小类。

内联类是它的反向手法。当一个类的职责被一点点搬走,最后只剩一两个字段和转发方法,它就该被并回去。比如物流系统里的 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())
# 输出:
# 顺丰: SF1001

TrackingInformation 已经没有自己的行为,读代码的人看到 shipment.tracking.display() 还得多跳一层,并回去更直接。注意并回来的字段改了名:company 变成 shipping_company,number 变成 tracking_number。在 TrackingInformation 里,“公司”和“号码”有类名做上下文,不会被误解;并进 Shipment 之后,Shipment.number 可能是运单号,也可能是订单号,所以名字要把原来由类名提供的那部分意思补上。内联类时顺手做一次字段改名(第 31 章),是常见的收尾。另一个内联类的场合是重新划分职责:两个类的边界画错了,先把它们合成一个,再按新的理解重新提炼。

在老系统里做提炼类,最常碰到的阻力是数据库:person 表上已经有 office_area_code 和 office_number 两列。代码里的类可以先提炼出来,表结构不动,Person 从两列读出数据后构造一个 TelephoneNumber,保存时再拆回两列。等代码里所有地方都通过 TelephoneNumber 访问电话之后,再决定要不要把电话拆成单独的表。代码的结构和存储的结构可以分开演进,不必绑在同一次改动里。

几乎每个系统的 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})"
@dataclass
class 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。邮编校验也只写一处。

前端的发票组件常常一边负责显示,一边算税:税率表、四舍五入规则、免税品判断,全写在组件里。把计算提炼成 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
// 合计税额 35

InvoiceView 的构造参数默认创建一个 TaxCalculator,税率表又是 TaxCalculator 的构造参数,测试或者别的地区可以传进一个按别的税率算的实例。这是提炼类顺带得到的好处:拆出去的部分可以被替换。

提炼出来的类要不要对外公开,也值得想一想。如果调用方需要直接拿到电话号码去做别的事(比如拨号、发短信),就公开它,最好做成不可变的值对象;如果它只是原类内部的实现细节,就让原类保留转发方法,外部不知道它的存在,以后要换实现也不影响谁。公开得越多,将来改动的牵连就越大。

两三个字段、一两个方法的类,不要为了“单一职责”硬拆,拆出来的类没有自己的规则,只是多了一层。提炼的回报来自“这一组东西会独立变化”,如果它们总和类里别的部分一起改,拆开反而要同时改两个类。

也别一次拆出一个大类。先拆出最明显的一组,跑测试、提交,再看剩下的部分是否还需要拆。

手法 它关心什么 提炼类关心什么
提炼超类(第 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,个税 8400
import 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,个税 8400
import 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);
});
  • 几个字段有共同前缀、几个方法只用其中一部分字段,就是一个还没独立出来的类。
  • 提炼时先建新类、再一个个搬字段、再搬方法,原类留下转发,测试不用改。
  • 提炼出的小类常常值得做成不可变的值对象。
  • 内联类是反向手法:一个类职责被搬空,只剩一层壳时并回去。
  • 提炼类是“组合优于继承”最常见的日常形态。