函数上移 / 函数下移
本章讲五个手法:函数上移、字段上移、构造函数本体上移、函数下移、字段下移。它们在父类和子类之间搬东西:几个子类里一样的函数、字段、构造函数开头搬到父类,叫上移;父类里只有一个子类用得上的函数、字段搬到那个子类,叫下移。 它们解决的痛是:同一段代码在每个子类里抄一遍,改一处漏一处;或者父类替某一个子类背着东西,其他子类被迫继承一堆用不上、还可能用错的成员。
小林接手公司的人事系统。员工分工程师和销售两类,代码里是父类 Employee 和两个子类 Engineer、Salesman。两个子类是不同的人在不同时间写的:先有工程师,后来有人照着工程师抄了一份改成销售;再后来为了算销售的业绩,又有人图省事把“销售指标”和“提成”直接加在了 Employee 上。现在要加第三类员工:经理。
下面五段代码各对应一个手法,都取自这套人事系统的不同角落,每段只保留和该手法有关的部分。
字段上移的现场
Section titled “字段上移的现场”两个子类各自存一个 name。花名册函数要列出所有人的名字,参数类型只能写成“工程师或销售”,因为 Employee 本身没有 name。
"""字段上移:重构前。"""from abc import ABC, abstractmethod
class Employee(ABC): @abstractmethod def role(self) -> str: ...
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: self.name = name self.level = level
def role(self) -> str: return f"{self.level} 级工程师"
class Salesman(Employee): def __init__(self, name: str, region: str) -> None: self.name = name self.region = region
def role(self) -> str: return f"{self.region}区销售"
def roster(staff: list[Engineer | Salesman]) -> list[str]: return [f"{e.name}:{e.role()}" for e in staff]
if __name__ == "__main__": for line in roster([Engineer("张伟", 3), Salesman("李娜", "华东")]): print(line)
# 输出:# 张伟:3 级工程师# 李娜:华东区销售// 字段上移:重构前。export abstract class Employee { abstract role(): string;}
export class Engineer extends Employee { constructor( readonly name: string, readonly level: number, ) { super(); }
role(): string { return `${this.level} 级工程师`; }}
export class Salesman extends Employee { constructor( readonly name: string, readonly region: string, ) { super(); }
role(): string { return `${this.region}区销售`; }}
export function roster(staff: (Engineer | Salesman)[]): string[] { return staff.map((e) => `${e.name}:${e.role()}`);}
for (const line of roster([new Engineer("张伟", 3), new Salesman("李娜", "华东")])) { console.log(line);}
// 输出:// 张伟:3 级工程师// 李娜:华东区销售import unittest
from target import Engineer, Salesman, roster
class RosterTest(unittest.TestCase): def test_lists_name_and_role(self): staff = [Engineer("张伟", 3), Salesman("李娜", "华东")] self.assertEqual(roster(staff), ["张伟:3 级工程师", "李娜:华东区销售"])
def test_every_employee_has_a_name(self): self.assertEqual(Engineer("王强", 2).name, "王强") self.assertEqual(Salesman("赵敏", "华南").name, "赵敏")
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Engineer, Salesman, roster } from "./target";
test("花名册列出名字和角色", () => { const staff = [new Engineer("张伟", 3), new Salesman("李娜", "华东")]; assert.deepEqual(roster(staff), ["张伟:3 级工程师", "李娜:华东区销售"]);});
test("每个员工都有名字", () => { assert.equal(new Engineer("王强", 2).name, "王强"); assert.equal(new Salesman("赵敏", "华南").name, "赵敏");});构造函数本体上移的现场
Section titled “构造函数本体上移的现场”Employee 已经管着名字,但两个子类的构造函数里还各有一段一模一样的月成本校验和赋值。
"""构造函数本体上移:重构前。"""
class Employee: def __init__(self, name: str) -> None: self.name = name
class Engineer(Employee): def __init__(self, name: str, monthly_cost: int, level: int) -> None: super().__init__(name) if monthly_cost <= 0: raise ValueError(f"{name}的月成本必须是正数") self.monthly_cost = monthly_cost self.level = level
class Salesman(Employee): def __init__(self, name: str, monthly_cost: int, region: str) -> None: super().__init__(name) if monthly_cost <= 0: raise ValueError(f"{name}的月成本必须是正数") self.monthly_cost = monthly_cost self.region = region
if __name__ == "__main__": print(Engineer("张伟", 30000, 3).monthly_cost) try: Salesman("李娜", 0, "华东") except ValueError as error: print(error)
# 输出:# 30000# 李娜的月成本必须是正数// 构造函数本体上移:重构前。export class Employee { constructor(readonly name: string) {}}
export class Engineer extends Employee { readonly monthlyCost: number;
constructor( name: string, monthlyCost: number, readonly level: number, ) { super(name); if (monthlyCost <= 0) throw new Error(`${name}的月成本必须是正数`); this.monthlyCost = monthlyCost; }}
export class Salesman extends Employee { readonly monthlyCost: number;
constructor( name: string, monthlyCost: number, readonly region: string, ) { super(name); if (monthlyCost <= 0) throw new Error(`${name}的月成本必须是正数`); this.monthlyCost = monthlyCost; }}
console.log(new Engineer("张伟", 30000, 3).monthlyCost);try { new Salesman("李娜", 0, "华东");} catch (error) { if (error instanceof Error) console.log(error.message);}
// 输出:// 30000// 李娜的月成本必须是正数import unittest
from target import Engineer, Salesman
class MonthlyCostTest(unittest.TestCase): def test_keeps_monthly_cost(self): self.assertEqual(Engineer("张伟", 30000, 3).monthly_cost, 30000) self.assertEqual(Salesman("李娜", 20000, "华东").monthly_cost, 20000)
def test_rejects_non_positive_cost(self): with self.assertRaisesRegex(ValueError, "李娜的月成本必须是正数"): Salesman("李娜", 0, "华东") with self.assertRaisesRegex(ValueError, "张伟的月成本必须是正数"): Engineer("张伟", -1, 3)
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Engineer, Salesman } from "./target";
test("记住月成本", () => { assert.equal(new Engineer("张伟", 30000, 3).monthlyCost, 30000); assert.equal(new Salesman("李娜", 20000, "华东").monthlyCost, 20000);});
test("月成本不是正数就拒绝", () => { assert.throws(() => new Salesman("李娜", 0, "华东"), /李娜的月成本必须是正数/); assert.throws(() => new Engineer("张伟", -1, 3), /张伟的月成本必须是正数/);});函数上移的现场
Section titled “函数上移的现场”两个子类各有一个 annual_cost。写法略有不同,一个是 monthly_cost * 12,一个是 12 * monthly_cost,算的是同一件事。
"""函数上移:重构前。"""
class Employee: def __init__(self, name: str, monthly_cost: int) -> None: self.name = name self.monthly_cost = monthly_cost
class Engineer(Employee): def __init__(self, name: str, monthly_cost: int, level: int) -> None: super().__init__(name, monthly_cost) self.level = level
def annual_cost(self) -> int: return self.monthly_cost * 12
class Salesman(Employee): def __init__(self, name: str, monthly_cost: int, region: str) -> None: super().__init__(name, monthly_cost) self.region = region
def annual_cost(self) -> int: return 12 * self.monthly_cost
def total_cost(staff: list[Engineer | Salesman]) -> int: return sum(e.annual_cost() for e in staff)
if __name__ == "__main__": staff = [Engineer("张伟", 30000, 3), Salesman("李娜", 20000, "华东")] for e in staff: print(e.name, e.annual_cost()) print("合计", total_cost(staff))
# 输出:# 张伟 360000# 李娜 240000# 合计 600000// 函数上移:重构前。export class Employee { constructor( readonly name: string, readonly monthlyCost: number, ) {}}
export class Engineer extends Employee { constructor( name: string, monthlyCost: number, readonly level: number, ) { super(name, monthlyCost); }
annualCost(): number { return this.monthlyCost * 12; }}
export class Salesman extends Employee { constructor( name: string, monthlyCost: number, readonly region: string, ) { super(name, monthlyCost); }
annualCost(): number { return 12 * this.monthlyCost; }}
export function totalCost(staff: (Engineer | Salesman)[]): number { return staff.reduce((sum, e) => sum + e.annualCost(), 0);}
const staff = [new Engineer("张伟", 30000, 3), new Salesman("李娜", 20000, "华东")];for (const e of staff) { console.log(e.name, e.annualCost());}console.log("合计", totalCost(staff));
// 输出:// 张伟 360000// 李娜 240000// 合计 600000import unittest
from target import Engineer, Salesman, total_cost
class AnnualCostTest(unittest.TestCase): def test_annual_cost_is_twelve_months(self): self.assertEqual(Engineer("张伟", 30000, 3).annual_cost(), 360000) self.assertEqual(Salesman("李娜", 20000, "华东").annual_cost(), 240000)
def test_total_cost(self): staff = [Engineer("张伟", 30000, 3), Salesman("李娜", 20000, "华东")] self.assertEqual(total_cost(staff), 600000)
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Engineer, Salesman, totalCost } from "./target";
test("年成本是十二个月", () => { assert.equal(new Engineer("张伟", 30000, 3).annualCost(), 360000); assert.equal(new Salesman("李娜", 20000, "华东").annualCost(), 240000);});
test("合计", () => { assert.equal(totalCost([new Engineer("张伟", 30000, 3), new Salesman("李娜", 20000, "华东")]), 600000);});函数下移的现场
Section titled “函数下移的现场”提成只有销售有,算提成的方法却写在 Employee 上,于是每个工程师对象也带着一个 commission。
"""函数下移:重构前。"""
class Employee: def __init__(self, name: str) -> None: self.name = name
def commission(self, sales: int) -> float: return sales * 3 / 100
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: super().__init__(name) self.level = level
class Salesman(Employee): def __init__(self, name: str, region: str) -> None: super().__init__(name) self.region = region
def commission_report(team: list[Salesman], sales: dict[str, int]) -> list[str]: return [f"{s.name} 提成 {s.commission(sales[s.name]):.2f}" for s in team]
if __name__ == "__main__": team = [Salesman("李娜", "华东"), Salesman("赵敏", "华南")] for line in commission_report(team, {"李娜": 100000, "赵敏": 80000}): print(line)
# 输出:# 李娜 提成 3000.00# 赵敏 提成 2400.00// 函数下移:重构前。export class Employee { constructor(readonly name: string) {}
commission(sales: number): number { return (sales * 3) / 100; }}
export class Engineer extends Employee { constructor( name: string, readonly level: number, ) { super(name); }}
export class Salesman extends Employee { constructor( name: string, readonly region: string, ) { super(name); }}
export function commissionReport(team: Salesman[], sales: Record<string, number>): string[] { return team.map((s) => `${s.name} 提成 ${s.commission(sales[s.name]).toFixed(2)}`);}
const team = [new Salesman("李娜", "华东"), new Salesman("赵敏", "华南")];for (const line of commissionReport(team, { 李娜: 100000, 赵敏: 80000 })) { console.log(line);}
// 输出:// 李娜 提成 3000.00// 赵敏 提成 2400.00import unittest
from target import Salesman, commission_report
class CommissionTest(unittest.TestCase): def test_three_percent_of_sales(self): self.assertEqual(Salesman("李娜", "华东").commission(100000), 3000)
def test_report(self): team = [Salesman("李娜", "华东"), Salesman("赵敏", "华南")] report = commission_report(team, {"李娜": 100000, "赵敏": 80000}) self.assertEqual(report, ["李娜 提成 3000.00", "赵敏 提成 2400.00"])
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Salesman, commissionReport } from "./target";
test("提成是销售额的 3%", () => { assert.equal(new Salesman("李娜", "华东").commission(100000), 3000);});
test("提成报表", () => { const team = [new Salesman("李娜", "华东"), new Salesman("赵敏", "华南")]; assert.deepEqual(commissionReport(team, { 李娜: 100000, 赵敏: 80000 }), ["李娜 提成 3000.00", "赵敏 提成 2400.00"]);});字段下移的现场
Section titled “字段下移的现场”销售指标 quota 也放在了 Employee 上,默认值 0。工程师没有指标,却人人带着一个 quota = 0。
"""字段下移:重构前。"""
class Employee: def __init__(self, name: str, quota: int = 0) -> None: self.name = name self.quota = quota
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: super().__init__(name) self.level = level
class Salesman(Employee): def __init__(self, name: str, region: str, quota: int) -> None: super().__init__(name, quota) self.region = region
def quota_met(self, sales: int) -> bool: return sales >= self.quota
if __name__ == "__main__": li = Salesman("李娜", "华东", 500000) print(li.name, li.quota_met(600000), li.quota_met(400000)) print(Engineer("张伟", 3).name)
# 输出:# 李娜 True False# 张伟// 字段下移:重构前。export class Employee { constructor( readonly name: string, readonly quota: number = 0, ) {}}
export class Engineer extends Employee { constructor( name: string, readonly level: number, ) { super(name); }}
export class Salesman extends Employee { constructor( name: string, readonly region: string, quota: number, ) { super(name, quota); }
quotaMet(sales: number): boolean { return sales >= this.quota; }}
const li = new Salesman("李娜", "华东", 500000);console.log(li.name, li.quotaMet(600000), li.quotaMet(400000));console.log(new Engineer("张伟", 3).name);
// 输出:// 李娜 true false// 张伟import unittest
from target import Engineer, Salesman
class QuotaTest(unittest.TestCase): def test_quota_met(self): li = Salesman("李娜", "华东", 500000) self.assertTrue(li.quota_met(500000)) self.assertFalse(li.quota_met(499999))
def test_salesman_keeps_quota(self): self.assertEqual(Salesman("李娜", "华东", 500000).quota, 500000)
def test_engineer_still_works(self): self.assertEqual(Engineer("张伟", 3).level, 3)
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Engineer, Salesman } from "./target";
test("是否完成指标", () => { const li = new Salesman("李娜", "华东", 500000); assert.equal(li.quotaMet(500000), true); assert.equal(li.quotaMet(499999), false);});
test("销售记得自己的指标", () => { assert.equal(new Salesman("李娜", "华东", 500000).quota, 500000);});
test("工程师照常构造", () => { assert.equal(new Engineer("张伟", 3).level, 3);});如果现在要加经理 Manager,小林得把下面这些事做一遍。
先看上移那一侧。经理也有名字,于是 Manager 的构造函数里再写一次 self.name = name;花名册 roster 和成本合计 total_cost 的参数类型从“工程师或销售”改成“工程师或销售或经理”,有几个这样的函数就要改几处,漏改一个,Python 的类型检查器报错,TS 直接编译不过。经理也有月成本,于是那段校验再抄一份;哪天财务说月成本可以为 0(比如停薪留职),要改的是三个构造函数,改了两个、漏了一个,就会出现“工程师可以填 0、经理不行”的怪事。annual_cost 也要抄第三份,而且下一个抄的人看到两份写法不同的版本,会犹豫:monthly_cost * 12 和 12 * monthly_cost 是不是故意不同?要读三遍才敢确定它们一样。
再看下移那一侧。经理从 Employee 继承来一个 commission 和一个 quota = 0。对经理调用 commission(1000000) 会安安静静地返回 30000,没有任何报错,谁要是写个循环给“所有员工”算提成,经理和工程师也会领到一笔。quota 更麻烦:0 到底是“没有指标”还是“指标为零”?已经有人在报表里写了 if e.quota == 0 来判断谁是工程师,加了经理之后这个判断就把经理也算了进去。读 Employee 的人还会被误导,以为“每个员工都有销售指标”是公司的规则。
用第 2 章的名字说,上移那一侧是重复代码,下移那一侧是被拒绝的遗赠:子类继承了父类的东西,却用不上也不想要。
字段上移之后
Section titled “字段上移之后”"""字段上移:重构后。"""from abc import ABC, abstractmethod
class Employee(ABC): def __init__(self, name: str) -> None: self.name = name
@abstractmethod def role(self) -> str: ...
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: super().__init__(name) self.level = level
def role(self) -> str: return f"{self.level} 级工程师"
class Salesman(Employee): def __init__(self, name: str, region: str) -> None: super().__init__(name) self.region = region
def role(self) -> str: return f"{self.region}区销售"
def roster(staff: list[Employee]) -> list[str]: return [f"{e.name}:{e.role()}" for e in staff]
if __name__ == "__main__": for line in roster([Engineer("张伟", 3), Salesman("李娜", "华东")]): print(line)
# 输出:# 张伟:3 级工程师# 李娜:华东区销售// 字段上移:重构后。export abstract class Employee { constructor(readonly name: string) {}
abstract role(): string;}
export class Engineer extends Employee { constructor( name: string, readonly level: number, ) { super(name); }
role(): string { return `${this.level} 级工程师`; }}
export class Salesman extends Employee { constructor( name: string, readonly region: string, ) { super(name); }
role(): string { return `${this.region}区销售`; }}
export function roster(staff: Employee[]): string[] { return staff.map((e) => `${e.name}:${e.role()}`);}
for (const line of roster([new Engineer("张伟", 3), new Salesman("李娜", "华东")])) { console.log(line);}
// 输出:// 张伟:3 级工程师// 李娜:华东区销售roster 的参数变成了 list[Employee]。加经理时它一个字不用改,经理只要是 Employee 就有名字。在 Python 和 TS 里,字段是在构造函数里诞生的,所以字段上移通常连带着把赋值语句也搬进父类构造函数,子类改成调用 super。
构造函数本体上移之后
Section titled “构造函数本体上移之后”"""构造函数本体上移:重构后。"""
class Employee: def __init__(self, name: str, monthly_cost: int) -> None: if monthly_cost <= 0: raise ValueError(f"{name}的月成本必须是正数") self.name = name self.monthly_cost = monthly_cost
class Engineer(Employee): def __init__(self, name: str, monthly_cost: int, level: int) -> None: super().__init__(name, monthly_cost) self.level = level
class Salesman(Employee): def __init__(self, name: str, monthly_cost: int, region: str) -> None: super().__init__(name, monthly_cost) self.region = region
if __name__ == "__main__": print(Engineer("张伟", 30000, 3).monthly_cost) try: Salesman("李娜", 0, "华东") except ValueError as error: print(error)
# 输出:# 30000# 李娜的月成本必须是正数// 构造函数本体上移:重构后。export class Employee { constructor( readonly name: string, readonly monthlyCost: number, ) { if (monthlyCost <= 0) throw new Error(`${name}的月成本必须是正数`); }}
export class Engineer extends Employee { constructor( name: string, monthlyCost: number, readonly level: number, ) { super(name, monthlyCost); }}
export class Salesman extends Employee { constructor( name: string, monthlyCost: number, readonly region: string, ) { super(name, monthlyCost); }}
console.log(new Engineer("张伟", 30000, 3).monthlyCost);try { new Salesman("李娜", 0, "华东");} catch (error) { if (error instanceof Error) console.log(error.message);}
// 输出:// 30000// 李娜的月成本必须是正数校验规则只剩一份。财务改口说月成本可以为 0,改 Employee 一处,三类员工同时生效。子类构造函数只剩下自己独有的那一个字段。
函数上移之后
Section titled “函数上移之后”"""函数上移:重构后。"""
class Employee: def __init__(self, name: str, monthly_cost: int) -> None: self.name = name self.monthly_cost = monthly_cost
def annual_cost(self) -> int: return self.monthly_cost * 12
class Engineer(Employee): def __init__(self, name: str, monthly_cost: int, level: int) -> None: super().__init__(name, monthly_cost) self.level = level
class Salesman(Employee): def __init__(self, name: str, monthly_cost: int, region: str) -> None: super().__init__(name, monthly_cost) self.region = region
def total_cost(staff: list[Employee]) -> int: return sum(e.annual_cost() for e in staff)
if __name__ == "__main__": staff = [Engineer("张伟", 30000, 3), Salesman("李娜", 20000, "华东")] for e in staff: print(e.name, e.annual_cost()) print("合计", total_cost(staff))
# 输出:# 张伟 360000# 李娜 240000# 合计 600000// 函数上移:重构后。export class Employee { constructor( readonly name: string, readonly monthlyCost: number, ) {}
annualCost(): number { return this.monthlyCost * 12; }}
export class Engineer extends Employee { constructor( name: string, monthlyCost: number, readonly level: number, ) { super(name, monthlyCost); }}
export class Salesman extends Employee { constructor( name: string, monthlyCost: number, readonly region: string, ) { super(name, monthlyCost); }}
export function totalCost(staff: Employee[]): number { return staff.reduce((sum, e) => sum + e.annualCost(), 0);}
const staff = [new Engineer("张伟", 30000, 3), new Salesman("李娜", 20000, "华东")];for (const e of staff) { console.log(e.name, e.annualCost());}console.log("合计", totalCost(staff));
// 输出:// 张伟 360000// 李娜 240000// 合计 600000annual_cost 只剩一份,经理直接继承。total_cost 也能接收 list[Employee] 了。注意上移的前提:TS 里父类方法要用 this.monthlyCost,父类必须先有这个字段,否则编译不过。所以三个上移手法常按“字段、构造函数、函数”的顺序做,前一步给后一步铺路。
函数下移之后
Section titled “函数下移之后”"""函数下移:重构后。"""
class Employee: def __init__(self, name: str) -> None: self.name = name
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: super().__init__(name) self.level = level
class Salesman(Employee): def __init__(self, name: str, region: str) -> None: super().__init__(name) self.region = region
def commission(self, sales: int) -> float: return sales * 3 / 100
def commission_report(team: list[Salesman], sales: dict[str, int]) -> list[str]: return [f"{s.name} 提成 {s.commission(sales[s.name]):.2f}" for s in team]
if __name__ == "__main__": team = [Salesman("李娜", "华东"), Salesman("赵敏", "华南")] for line in commission_report(team, {"李娜": 100000, "赵敏": 80000}): print(line)
# 输出:# 李娜 提成 3000.00# 赵敏 提成 2400.00// 函数下移:重构后。export class Employee { constructor(readonly name: string) {}}
export class Engineer extends Employee { constructor( name: string, readonly level: number, ) { super(name); }}
export class Salesman extends Employee { constructor( name: string, readonly region: string, ) { super(name); }
commission(sales: number): number { return (sales * 3) / 100; }}
export function commissionReport(team: Salesman[], sales: Record<string, number>): string[] { return team.map((s) => `${s.name} 提成 ${s.commission(sales[s.name]).toFixed(2)}`);}
const team = [new Salesman("李娜", "华东"), new Salesman("赵敏", "华南")];for (const line of commissionReport(team, { 李娜: 100000, 赵敏: 80000 })) { console.log(line);}
// 输出:// 李娜 提成 3000.00// 赵敏 提成 2400.00工程师和以后的经理不再有 commission。有人对工程师调用它,Python 抛 AttributeError,TS 在编译时就报错,“给工程师算提成”这种错误从静默变成了响亮。
字段下移之后
Section titled “字段下移之后”"""字段下移:重构后。"""
class Employee: def __init__(self, name: str) -> None: self.name = name
class Engineer(Employee): def __init__(self, name: str, level: int) -> None: super().__init__(name) self.level = level
class Salesman(Employee): def __init__(self, name: str, region: str, quota: int) -> None: super().__init__(name) self.region = region self.quota = quota
def quota_met(self, sales: int) -> bool: return sales >= self.quota
if __name__ == "__main__": li = Salesman("李娜", "华东", 500000) print(li.name, li.quota_met(600000), li.quota_met(400000)) print(Engineer("张伟", 3).name)
# 输出:# 李娜 True False# 张伟// 字段下移:重构后。export class Employee { constructor(readonly name: string) {}}
export class Engineer extends Employee { constructor( name: string, readonly level: number, ) { super(name); }}
export class Salesman extends Employee { constructor( name: string, readonly region: string, readonly quota: number, ) { super(name); }
quotaMet(sales: number): boolean { return sales >= this.quota; }}
const li = new Salesman("李娜", "华东", 500000);console.log(li.name, li.quotaMet(600000), li.quotaMet(400000));console.log(new Engineer("张伟", 3).name);
// 输出:// 李娜 true false// 张伟Employee 的构造函数少了一个带默认值的参数,“0 是没有指标还是指标为零”这个问题不复存在:没有指标的员工根本没有这个字段。if e.quota == 0 这种靠默认值猜身份的写法也就写不出来了。
五段代码的输出和重构前逐字相同,五组测试一个字没改,在重构前后都通过。
怎么一步步做
Section titled “怎么一步步做”字段上移
- 检查各子类里的这个字段是不是同一个东西:名字相同、含义相同、用法相同。名字不同但含义相同的,先用字段改名(第 31 章)统一名字。(IDE 可自动完成)
- 在父类构造函数里加上这个参数和赋值,子类构造函数改成把值传给
super,删掉子类自己的赋值。TS 里同时把字段声明搬到父类。 - 跑测试。然后把只接收“子类甲或子类乙”的函数参数类型放宽成父类。
构造函数本体上移
- 父类还没有构造函数的,先建一个,子类改成调用它。
- 把各子类构造函数里相同的语句搬进父类,需要的值作为参数传上去。跑测试。
- 如果相同的语句排在子类独有语句的后面,而且依赖子类数据,就不能直接上移:TS 里
super()之前不能使用this。这时先用提炼函数(第 4 章)把这段公共逻辑提成一个方法,再用函数上移把方法搬到父类,子类构造函数在合适的位置调用它。
函数上移
- 确认各子类的函数做的是同一件事。像
monthly_cost * 12和12 * monthly_cost这样写法不同的,先改成一模一样,跑测试,确认它们真的等价。 - 检查函数体用到的字段和方法在父类里是否都有,没有的先上移它们。
- 把函数复制到父类,跑测试;再逐个删掉子类里的版本,每删一个跑一次测试。PyCharm 和 WebStorm 的 Pull Members Up 能完成搬移和删除(IDE 可自动完成)。
函数下移和字段下移
- 找出真正用到它的子类。用 IDE 的 Find Usages 查调用点,看调用者手里拿的是哪个子类。
- 把函数或字段复制到用到它的子类,跑测试。字段下移时,构造参数也跟着从父类挪到子类。
- 从父类删掉,跑测试。如果有调用者是通过父类类型调用的,测试或类型检查会指出来,说明它其实只该拿着那个子类。PyCharm 和 WebStorm 的 Push Members Down 能完成搬移(IDE 可自动完成)。
有函数和字段要一起下移时,先移函数后移字段:函数还在父类时,字段必须也在父类,否则 TS 编译不过。
定义与适用场景
Section titled “定义与适用场景”上移:把几个子类里相同的函数、字段或构造函数语句搬到它们共同的父类。下移:把父类里只与某个子类相关的函数或字段搬到那个子类。
上移最常出现在“照着抄”的继承体系里:第一个子类写好了,第二个子类复制过来改几处,相同的部分就留下了双份。另一个常见来源是第 54 章提炼超类:刚提出父类时它是空的,接下来的工作就是一个个地上移。判断能不能上移,看的是“是不是同一件事”,而不是“代码长得像不像”。两个 annual_cost 写法不同却是同一件事,可以上移;两个都叫 price 的方法,一个含税一个不含税,长得再像也不能合并。
下移出现在父类被当成了“公共储物间”的时候:某个子类需要一个字段,加在父类最方便,于是就加在了父类。时间一长,父类变成所有子类的并集,每个子类都背着别人的东西。判断标准是看调用者:如果一个方法的所有调用者手里拿的都是同一个子类,它就该住在那个子类里。
上移和下移互为反向。两个方向不是谁对谁错,而是随需求移动:今天只有销售有提成,提成就该在销售里;明年经理也有提成了,就再把它上移,或者提炼出一个“有提成的员工”中间层。
相关的设计模式
Section titled “相关的设计模式”函数上移经常停在一半:两个子类的函数大部分相同,只有一两步不同。比如销售的年成本要在十二个月工资之外再加年度提成,工程师的不用。这时上移的是整个流程,不同的那一步留在子类里作为一个可覆盖的方法:Employee.annual_cost() 返回 self.monthly_cost * 12 + self.annual_bonus(),annual_bonus() 在父类里默认返回 0,Salesman 覆盖它。这就是模板方法(Template Method)模式:父类的 annual_cost 是模板方法,定下步骤和顺序;annual_bonus 是钩子,留给子类填空。下面真实例子里 Django 的 ListView.get 和 get_queryset 也是同样的分工。模板方法见《设计模式》书第 20 章。
真实系统里的例子
Section titled “真实系统里的例子”Django 的类视图
Section titled “Django 的类视图”Django 的类视图(class-based views)就是函数上移的产物。早期项目里每个列表页都是一个视图函数,各写一遍“取第几页、切片、渲染”;Django 把这段公共流程上移到 ListView 里,子类只提供 get_queryset。下面用字典模拟请求,ArticleListV1、ProductListV1 是上移前的两个视图,V2 是上移后的。
"""模拟 Django 的类视图:把两个列表视图里一样的 get 上移到基类。"""
ARTICLES = ["重构入门", "测试先行", "代码坏味道"]PRODUCTS = ["咖啡豆", "手冲壶", "滤纸", "磨豆机"]PAGE_SIZE = 2
class View: """模拟 Django 的 View:按请求方法分发到同名方法。"""
def dispatch(self, request: dict) -> str: return getattr(self, request["method"].lower())(request)
# 上移前:两个视图各写一遍分页和渲染。class ArticleListV1(View): def get(self, request: dict) -> str: page = int(request.get("page", 1)) items = ARTICLES[(page - 1) * PAGE_SIZE : page * PAGE_SIZE] return f"文章 第{page}页:{'、'.join(items)}"
class ProductListV1(View): def get(self, request: dict) -> str: page = int(request.get("page", 1)) items = PRODUCTS[(page - 1) * PAGE_SIZE : page * PAGE_SIZE] return f"商品 第{page}页:{'、'.join(items)}"
# 上移后:get 住在 ListView 里,子类只说"列什么、叫什么"。class ListView(View): title = ""
def get_queryset(self) -> list[str]: raise NotImplementedError
def get(self, request: dict) -> str: page = int(request.get("page", 1)) items = self.get_queryset()[(page - 1) * PAGE_SIZE : page * PAGE_SIZE] return f"{self.title} 第{page}页:{'、'.join(items)}"
class ArticleListV2(ListView): title = "文章"
def get_queryset(self) -> list[str]: return ARTICLES
class ProductListV2(ListView): title = "商品"
def get_queryset(self) -> list[str]: return PRODUCTS
if __name__ == "__main__": request = {"method": "GET", "page": "2"} print(ArticleListV1().dispatch(request) == ArticleListV2().dispatch(request)) print(ProductListV1().dispatch(request) == ProductListV2().dispatch(request)) print(ProductListV2().dispatch(request))
# 输出:# True# True# 商品 第2页:滤纸、磨豆机上移之后,要给所有列表页加“每页条数可配置”,改 ListView.get 一处即可。真正的 Django 走得更远:paginate_by、template_name 都是可以在子类里覆盖的类属性,分页、取数据、渲染模板各是一个可覆盖的方法。
前端表单组件的基类
Section titled “前端表单组件的基类”前端项目里,登录表单和注册表单常常是两个组件,各写一遍“检查必填项、拼错误提示”。下面用普通的类模拟组件,render 返回要显示的文字。上移之后,校验逻辑住在 FormComponent 里,子类只声明标题和必填字段。
// 模拟前端表单组件:把两个表单里一样的必填校验上移到基类。type Values = Record<string, string>;
// 上移前:两个组件各写一遍校验。class LoginFormV1 { readonly required = ["email", "password"];
validate(values: Values): string[] { return this.required.filter((f) => !values[f]?.trim()).map((f) => `${f} 必填`); }
render(values: Values): string { const errors = this.validate(values); return errors.length > 0 ? `登录表单:${errors.join(",")}` : "登录表单:可以提交"; }}
class SignupFormV1 { readonly required = ["email", "password", "nickname"];
validate(values: Values): string[] { return this.required.filter((f) => !values[f]?.trim()).map((f) => `${f} 必填`); }
render(values: Values): string { const errors = this.validate(values); return errors.length > 0 ? `注册表单:${errors.join(",")}` : "注册表单:可以提交"; }}
// 上移后:校验和渲染住在基类里。abstract class FormComponent { abstract readonly title: string; abstract readonly required: string[];
validate(values: Values): string[] { return this.required.filter((f) => !values[f]?.trim()).map((f) => `${f} 必填`); }
render(values: Values): string { const errors = this.validate(values); return errors.length > 0 ? `${this.title}:${errors.join(",")}` : `${this.title}:可以提交`; }}
class LoginFormV2 extends FormComponent { readonly title = "登录表单"; readonly required = ["email", "password"];}
class SignupFormV2 extends FormComponent { readonly title = "注册表单"; readonly required = ["email", "password", "nickname"];}
const values: Values = { email: "lin@example.com", password: " " };console.log(new LoginFormV1().render(values) === new LoginFormV2().render(values));console.log(new SignupFormV1().render(values) === new SignupFormV2().render(values));console.log(new SignupFormV2().render(values));
// 输出:// true// true// 注册表单:password 必填,nickname 必填上移之后,“只填空格也算没填”这条规则只写在一处。React 社区今天更常用的做法是组合:把校验写成一个函数或一个 hook,各组件调用它,而不是继承同一个基类。两种做法都消除了重复;组件之间除了校验还共享大量行为时,基类更顺手,只共享一两个工具函数时,组合更轻。第 56 章会讲从继承换到组合的手法。
什么时候别用
Section titled “什么时候别用”上移过头的样子是父类越来越胖:只要两个子类有点像的代码就往上搬,最后父类里一半的方法只被两个子类中的一个用,又回到了需要下移的状态。上移之前问一句:加第三个子类时,它也会需要这个东西吗?如果答案是“不一定”,宁可先留着两份重复,等第三个子类出现时再看。
另一个陷阱是把“巧合相同”当成“本质相同”。工程师和销售的年假天数今天碰巧都是 10 天,上移成父类的一个常量之后,下个月人事说销售改成 12 天,就得再下移回去,或者在父类里加一个按类型分支的判断,这比原来的重复更糟。公共的应该是规则,而不是今天碰巧相等的数值。
下移也有代价:调用方如果手里拿的是父类类型,下移之后就得先判断类型再调用。如果下移之后到处冒出 isinstance(e, Salesman),说明这个成员其实是父类层面的概念,只是不同子类的实现不同,这时该在父类里留一个抽象声明,让各子类给出自己的实现,而不是下移。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 上移、下移关心什么 |
|---|---|---|
| 搬移函数、搬移字段(第 21、22 章) | 成员在两个互不相干的类之间搬家 | 成员在同一继承体系的父子类之间上下移动 |
| 提炼超类(第 54 章) | 两个还没有共同父类的类,新建一个父类 | 已经有父类,往上或往下挪成员;提炼超类之后通常紧接着上移 |
| 以委托取代超类(第 56 章) | 子类根本不该继承这个父类,改成持有 | 继承关系没错,只是成员放错了层 |
下面的车辆类有两个问题:Car 和 Truck 的 label 一模一样,Vehicle 上的 cargo_kg 只有货车用。用函数上移把 label 搬到 Vehicle(不同的部分 summary 留在子类),用字段下移把 cargo_kg 搬到 Truck。
"""练习:车辆标签。"""
class Vehicle: def __init__(self, plate: str, cargo_kg: int = 0) -> None: self.plate = plate self.cargo_kg = cargo_kg
class Car(Vehicle): def __init__(self, plate: str, seats: int) -> None: super().__init__(plate) self.seats = seats
def summary(self) -> str: return f"{self.seats} 座轿车"
def label(self) -> str: return f"[{self.plate}] {self.summary()}"
class Truck(Vehicle): def __init__(self, plate: str, cargo_kg: int) -> None: super().__init__(plate, cargo_kg)
def summary(self) -> str: return f"载重 {self.cargo_kg} 公斤货车"
def label(self) -> str: return f"[{self.plate}] {self.summary()}"
if __name__ == "__main__": print(Car("沪A12345", 5).label()) print(Truck("沪B67890", 3000).label())
# 输出:# [沪A12345] 5 座轿车# [沪B67890] 载重 3000 公斤货车import unittest
from target import Car, Truck
class VehicleTest(unittest.TestCase): def test_car_label(self): self.assertEqual(Car("沪A12345", 5).label(), "[沪A12345] 5 座轿车")
def test_truck_label(self): self.assertEqual(Truck("沪B67890", 3000).label(), "[沪B67890] 载重 3000 公斤货车")
def test_truck_keeps_cargo(self): self.assertEqual(Truck("沪B67890", 3000).cargo_kg, 3000)
if __name__ == "__main__": unittest.main()// 练习:车辆标签。export class Vehicle { constructor( readonly plate: string, readonly cargoKg: number = 0, ) {}}
export class Car extends Vehicle { constructor( plate: string, readonly seats: number, ) { super(plate); }
summary(): string { return `${this.seats} 座轿车`; }
label(): string { return `[${this.plate}] ${this.summary()}`; }}
export class Truck extends Vehicle { constructor(plate: string, cargoKg: number) { super(plate, cargoKg); }
summary(): string { return `载重 ${this.cargoKg} 公斤货车`; }
label(): string { return `[${this.plate}] ${this.summary()}`; }}
console.log(new Car("沪A12345", 5).label());console.log(new Truck("沪B67890", 3000).label());
// 输出:// [沪A12345] 5 座轿车// [沪B67890] 载重 3000 公斤货车import test from "node:test";import assert from "node:assert/strict";import { Car, Truck } from "./target";
test("轿车标签", () => { assert.equal(new Car("沪A12345", 5).label(), "[沪A12345] 5 座轿车");});
test("货车标签", () => { assert.equal(new Truck("沪B67890", 3000).label(), "[沪B67890] 载重 3000 公斤货车");});
test("货车记得载重", () => { assert.equal(new Truck("沪B67890", 3000).cargoKg, 3000);});提示:label 上移后要调用 summary,父类得知道每个子类都有 summary。Python 里可以让 Vehicle 继承 ABC 并把 summary 声明为抽象方法,TS 里用 abstract summary(): string。
- 上移消除子类之间的重复:字段、构造函数语句、函数,通常按这个顺序做,前一步给后一步铺路。
- 能不能上移看“是不是同一件事”,不看代码长得像不像;写法不同的先改成一样,测试确认等价后再上移。
- 下移清理父类替某个子类背着的东西:所有调用者拿的都是同一个子类,这个成员就该住在那个子类里。
- 大部分相同、一两步不同的函数,上移流程、留下钩子,就是模板方法。
- 上移和下移随需求来回移动,别为今天碰巧相等的东西上移。