以子类取代类型码
以子类取代类型码(Replace Type Code with Subclasses)把一个表示“是哪一种”的字段,比如 type = "engineer",换成一组子类,每个取值一个子类,按类型分支的逻辑搬进各自的子类。
它解决的痛是:同一个类型字段在好几个方法里被 match、被 if 判断,每加一种类型就要把这些分支挨个找出来改一遍,漏掉一个就悄悄走进默认分支。
小周负责公司薪酬系统里的员工模块。员工按类型分工程师、经理、销售,数据库里存的是一个字符串 type。显示头衔要看类型,算年终奖要看类型,构造时校验也要看类型。年底人事说明年要招实习生,实习生的头衔和奖金规则都不一样,让小周先评估一下要改多少地方。
团队早就约定通过 create_employee 创建员工,不直接调用构造函数(第 48 章以工厂函数取代构造函数),所以下面的代码和测试都从它入手。
"""以子类取代类型码:重构前。"""
TYPES = ("engineer", "manager", "salesman")
class Employee: def __init__(self, name: str, type_: str) -> None: if type_ not in TYPES: raise ValueError(f"未知的员工类型:{type_}") self.name = name self.type = type_
def title(self) -> str: match self.type: case "engineer": return "工程师" case "manager": return "经理" case _: return "销售"
def bonus(self, monthly_salary: int) -> int: match self.type: case "engineer": return monthly_salary case "manager": return monthly_salary * 3 case _: return monthly_salary * 2
def __str__(self) -> str: return f"{self.name}({self.title()})"
def create_employee(name: str, type_: str) -> Employee: return Employee(name, type_)
if __name__ == "__main__": for name, type_ in [("张伟", "engineer"), ("王芳", "manager"), ("李娜", "salesman")]: e = create_employee(name, type_) print(e, "年终奖", e.bonus(20000)) try: create_employee("赵敏", "intern") except ValueError as error: print(error)
# 输出:# 张伟(工程师) 年终奖 20000# 王芳(经理) 年终奖 60000# 李娜(销售) 年终奖 40000# 未知的员工类型:intern// 以子类取代类型码:重构前。const TYPES = ["engineer", "manager", "salesman"];
export class Employee { constructor( readonly name: string, readonly type: string, ) { if (!TYPES.includes(type)) throw new Error(`未知的员工类型:${type}`); }
title(): string { switch (this.type) { case "engineer": return "工程师"; case "manager": return "经理"; default: return "销售"; } }
bonus(monthlySalary: number): number { switch (this.type) { case "engineer": return monthlySalary; case "manager": return monthlySalary * 3; default: return monthlySalary * 2; } }
toString(): string { return `${this.name}(${this.title()})`; }}
export function createEmployee(name: string, type: string): Employee { return new Employee(name, type);}
const people: [string, string][] = [ ["张伟", "engineer"], ["王芳", "manager"], ["李娜", "salesman"],];for (const [name, type] of people) { const e = createEmployee(name, type); console.log(`${e}`, "年终奖", e.bonus(20000));}try { createEmployee("赵敏", "intern");} catch (error) { if (error instanceof Error) console.log(error.message);}
// 输出:// 张伟(工程师) 年终奖 20000// 王芳(经理) 年终奖 60000// 李娜(销售) 年终奖 40000// 未知的员工类型:internTS 版的 type 用的是 string 而不是字面量联合类型,因为它和 Python 版一样来自数据库,构造时才校验。测试覆盖头衔、奖金、类型字段和非法类型:
import unittest
from target import create_employee
class EmployeeTest(unittest.TestCase): def test_title(self): self.assertEqual(str(create_employee("张伟", "engineer")), "张伟(工程师)") self.assertEqual(create_employee("李娜", "salesman").title(), "销售")
def test_bonus_by_type(self): self.assertEqual(create_employee("张伟", "engineer").bonus(20000), 20000) self.assertEqual(create_employee("王芳", "manager").bonus(20000), 60000) self.assertEqual(create_employee("李娜", "salesman").bonus(20000), 40000)
def test_type_is_still_readable(self): self.assertEqual(create_employee("王芳", "manager").type, "manager")
def test_unknown_type_is_rejected(self): with self.assertRaisesRegex(ValueError, "未知的员工类型:intern"): create_employee("赵敏", "intern")
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { createEmployee } from "./target";
test("头衔", () => { assert.equal(`${createEmployee("张伟", "engineer")}`, "张伟(工程师)"); assert.equal(createEmployee("李娜", "salesman").title(), "销售");});
test("按类型算年终奖", () => { assert.equal(createEmployee("张伟", "engineer").bonus(20000), 20000); assert.equal(createEmployee("王芳", "manager").bonus(20000), 60000); assert.equal(createEmployee("李娜", "salesman").bonus(20000), 40000);});
test("类型字段照样能读", () => { assert.equal(createEmployee("王芳", "manager").type, "manager");});
test("拒绝未知类型", () => { assert.throws(() => createEmployee("赵敏", "intern"), /未知的员工类型:intern/);});如果现在要加实习生 intern,小周要改三处:TYPES 里加一项,title 里加一个分支,bonus 里加一个分支。这还只是这一个类里的;系统里还有考勤、报销审批、权限几个模块也在读 employee.type,各自写着自己的 if e.type == "manager",得全局搜索字符串 "manager" 才能找全。
漏改的后果是安静的。假设他记得改 TYPES 和 bonus,忘了 title,实习生的头衔会显示成“销售”,因为 title 的最后一个分支是 case _。没有报错,没有测试失败,直到有人在工牌上看到“赵敏(销售)”。默认分支在写的时候是为了少写一行,加类型的时候它变成了一个陷阱。
读的时候也累。想知道“经理的全部规则”,要在 title 里找经理那一行,再到 bonus 里找经理那一行,再去别的模块里搜。关于经理的知识按方法切碎了,散在各处;而每个方法又按类型切成了三段,读一个方法要在脑子里同时装下三种员工。
用第 2 章的名字说,这是重复的 switch:同样按 type 分支的结构出现在多个方法里;而用一个裸字符串表示“员工是哪一种”,是基本类型偏执。
"""以子类取代类型码:重构后。"""from abc import ABC, abstractmethodfrom typing import ClassVar
class Employee(ABC): type: ClassVar[str]
def __init__(self, name: str) -> None: self.name = name
@abstractmethod def title(self) -> str: ...
@abstractmethod def bonus(self, monthly_salary: int) -> int: ...
def __str__(self) -> str: return f"{self.name}({self.title()})"
class Engineer(Employee): type = "engineer"
def title(self) -> str: return "工程师"
def bonus(self, monthly_salary: int) -> int: return monthly_salary
class Manager(Employee): type = "manager"
def title(self) -> str: return "经理"
def bonus(self, monthly_salary: int) -> int: return monthly_salary * 3
class Salesman(Employee): type = "salesman"
def title(self) -> str: return "销售"
def bonus(self, monthly_salary: int) -> int: return monthly_salary * 2
def create_employee(name: str, type_: str) -> Employee: match type_: case "engineer": return Engineer(name) case "manager": return Manager(name) case "salesman": return Salesman(name) case _: raise ValueError(f"未知的员工类型:{type_}")
if __name__ == "__main__": for name, type_ in [("张伟", "engineer"), ("王芳", "manager"), ("李娜", "salesman")]: e = create_employee(name, type_) print(e, "年终奖", e.bonus(20000)) try: create_employee("赵敏", "intern") except ValueError as error: print(error)
# 输出:# 张伟(工程师) 年终奖 20000# 王芳(经理) 年终奖 60000# 李娜(销售) 年终奖 40000# 未知的员工类型:intern// 以子类取代类型码:重构后。export abstract class Employee { abstract readonly type: string;
constructor(readonly name: string) {}
abstract title(): string; abstract bonus(monthlySalary: number): number;
toString(): string { return `${this.name}(${this.title()})`; }}
export class Engineer extends Employee { readonly type = "engineer";
title(): string { return "工程师"; }
bonus(monthlySalary: number): number { return monthlySalary; }}
export class Manager extends Employee { readonly type = "manager";
title(): string { return "经理"; }
bonus(monthlySalary: number): number { return monthlySalary * 3; }}
export class Salesman extends Employee { readonly type = "salesman";
title(): string { return "销售"; }
bonus(monthlySalary: number): number { return monthlySalary * 2; }}
export function createEmployee(name: string, type: string): Employee { switch (type) { case "engineer": return new Engineer(name); case "manager": return new Manager(name); case "salesman": return new Salesman(name); default: throw new Error(`未知的员工类型:${type}`); }}
const people: [string, string][] = [ ["张伟", "engineer"], ["王芳", "manager"], ["李娜", "salesman"],];for (const [name, type] of people) { const e = createEmployee(name, type); console.log(`${e}`, "年终奖", e.bonus(20000));}try { createEmployee("赵敏", "intern");} catch (error) { if (error instanceof Error) console.log(error.message);}
// 输出:// 张伟(工程师) 年终奖 20000// 王芳(经理) 年终奖 60000// 李娜(销售) 年终奖 40000// 未知的员工类型:intern输出一字不差,四个测试照常通过。再看上一节的几个问题。
加实习生现在是加一个类 Intern,再在 create_employee 里加一个分支。Employee 把 title 和 bonus 声明成了抽象方法,Intern 少写哪一个,Python 在实例化时就抛 TypeError,TS 在编译时就报错。“忘了改 title、头衔悄悄显示成销售”这种事不会再发生,因为已经没有那个默认分支了。
全系统里按类型分支的 match 只剩一个,就在工厂函数里。这一个是躲不掉的:数据库里存的是字符串,总得有一个地方把字符串翻译成类。但它只负责“创建哪个类”,不负责任何业务规则,加类型时改它不需要理解奖金怎么算。
想知道经理的全部规则,打开 Manager 就行,头衔和奖金挨在一起。别的模块如果也在按 type 判断,可以照同样的办法把那些逻辑搬进子类,最终 type 只剩下“存数据库时写什么”这一个用途。
type 字段保留了下来,变成每个子类上的常量。这是为了不打断还在读 employee.type 的代码,比如保存到数据库的那一段。
怎么一步步做
Section titled “怎么一步步做”Fowler 的做法分两段:先让子类出现,再把条件逻辑搬进去。每一步之后都跑测试。
- 自封装类型码:如果外部代码直接读写
type字段,先用封装变量(第 8 章)让它只通过一个取值函数访问。本例里type只在构造时设置、之后只读,可以跳过。 - 确保只有一个创建入口。如果代码里到处直接调用
Employee(name, type),先用以工厂函数取代构造函数(第 48 章)把它们收进create_employee。这一步是关键:之后要按类型返回不同的子类,只有工厂函数做得到,构造函数做不到。 - 挑一个类型值,比如
"engineer",建子类Engineer,让它覆盖类型码的取值,返回字面量"engineer"。在工厂函数里加一个分支:类型是"engineer"时返回Engineer。跑测试。 - 对
"manager"、"salesman"重复第 3 步,每加一个跑一次测试。 - 工厂函数的最后一个分支改成抛出“未知类型”错误,构造函数里的校验和
TYPES可以删了。父类里的type字段也删掉,改由各子类提供。跑测试。 - 对每个按类型分支的方法,用以多态取代条件表达式(第 38 章)把各分支搬进对应子类,父类里留下抽象声明。从
title开始,搬一个分支跑一次测试。只和某个子类相关的方法用函数下移(第 51 章)处理。
第 3 到第 5 步做完时,子类已经存在,但 title 和 bonus 里的 match 还在,还是按 self.type 分支,只不过 type 的值改由子类提供。这是一个可以停下来提交的中间状态,测试全部通过。第 6 步才真正收获好处,这一步和第 38 章是同一件事,本章不展开。
IDE 能帮上的是机械部分:建好子类之后,PyCharm 和 WebStorm 的 Push Members Down 可以把方法复制到子类(IDE 可自动完成)。“哪个分支属于哪个子类”的判断要自己做。
定义与适用场景
Section titled “定义与适用场景”以子类取代类型码:用一组子类代替一个表示对象种类的字段,每个类型值对应一个子类。
它常在两种场合出现。一是本章这样:同一个类型字段在多个方法里驱动条件分支,子类让每种类型的行为住在一起,再加类型就是加一个类。二是某些字段或方法只对某一种类型有意义,比如只有销售有业绩指标。有了子类之后,这些成员可以下移到 Salesman,工程师对象上就不会再挂着一个永远是 0 的 quota,这正是第 51 章字段下移的前提。
有时直接子类化做不到。最常见的原因是类型会变:工程师升职成经理,对象还是同一个对象,可子类在创建时就定下了,Python 和 TS 都不该在运行时改一个对象的类。另一个原因是这个类已经按别的维度有了子类,比如员工已经分了全职和兼职,再按岗位分就要组合出六个子类。这两种情况用间接继承:不给 Employee 建子类,而是给“员工类型”建一个类层次,Employee 持有一个类型对象,类型变了就换一个类型对象。
"""以子类取代类型码(间接继承):重构前。"""
TYPES = ("engineer", "manager", "salesman")
def check_type(type_: str) -> str: if type_ not in TYPES: raise ValueError(f"未知的员工类型:{type_}") return type_
class Employee: def __init__(self, name: str, type_: str) -> None: self.name = name self.type = check_type(type_)
def promote(self, new_type: str) -> None: self.type = check_type(new_type)
def bonus(self, monthly_salary: int) -> int: match self.type: case "engineer": return monthly_salary case "manager": return monthly_salary * 3 case _: return monthly_salary * 2
if __name__ == "__main__": zhang = Employee("张伟", "engineer") print(zhang.type, zhang.bonus(20000)) zhang.promote("manager") print(zhang.type, zhang.bonus(20000))
# 输出:# engineer 20000# manager 60000"""以子类取代类型码(间接继承):重构后。"""from abc import ABC, abstractmethodfrom typing import ClassVar
class EmployeeType(ABC): code: ClassVar[str]
@abstractmethod def bonus(self, monthly_salary: int) -> int: ...
class EngineerType(EmployeeType): code = "engineer"
def bonus(self, monthly_salary: int) -> int: return monthly_salary
class ManagerType(EmployeeType): code = "manager"
def bonus(self, monthly_salary: int) -> int: return monthly_salary * 3
class SalesmanType(EmployeeType): code = "salesman"
def bonus(self, monthly_salary: int) -> int: return monthly_salary * 2
def create_employee_type(code: str) -> EmployeeType: match code: case "engineer": return EngineerType() case "manager": return ManagerType() case "salesman": return SalesmanType() case _: raise ValueError(f"未知的员工类型:{code}")
class Employee: def __init__(self, name: str, type_: str) -> None: self.name = name self._type = create_employee_type(type_)
@property def type(self) -> str: return self._type.code
def promote(self, new_type: str) -> None: self._type = create_employee_type(new_type)
def bonus(self, monthly_salary: int) -> int: return self._type.bonus(monthly_salary)
if __name__ == "__main__": zhang = Employee("张伟", "engineer") print(zhang.type, zhang.bonus(20000)) zhang.promote("manager") print(zhang.type, zhang.bonus(20000))
# 输出:# engineer 20000# manager 60000// 以子类取代类型码(间接继承):重构前。const TYPES = ["engineer", "manager", "salesman"];
function checkType(type: string): string { if (!TYPES.includes(type)) throw new Error(`未知的员工类型:${type}`); return type;}
export class Employee { type: string;
constructor( readonly name: string, type: string, ) { this.type = checkType(type); }
promote(newType: string): void { this.type = checkType(newType); }
bonus(monthlySalary: number): number { switch (this.type) { case "engineer": return monthlySalary; case "manager": return monthlySalary * 3; default: return monthlySalary * 2; } }}
const zhang = new Employee("张伟", "engineer");console.log(zhang.type, zhang.bonus(20000));zhang.promote("manager");console.log(zhang.type, zhang.bonus(20000));
// 输出:// engineer 20000// manager 60000// 以子类取代类型码(间接继承):重构后。abstract class EmployeeType { abstract readonly code: string; abstract bonus(monthlySalary: number): number;}
class EngineerType extends EmployeeType { readonly code = "engineer";
bonus(monthlySalary: number): number { return monthlySalary; }}
class ManagerType extends EmployeeType { readonly code = "manager";
bonus(monthlySalary: number): number { return monthlySalary * 3; }}
class SalesmanType extends EmployeeType { readonly code = "salesman";
bonus(monthlySalary: number): number { return monthlySalary * 2; }}
function createEmployeeType(code: string): EmployeeType { switch (code) { case "engineer": return new EngineerType(); case "manager": return new ManagerType(); case "salesman": return new SalesmanType(); default: throw new Error(`未知的员工类型:${code}`); }}
export class Employee { private employeeType: EmployeeType;
constructor( readonly name: string, type: string, ) { this.employeeType = createEmployeeType(type); }
get type(): string { return this.employeeType.code; }
promote(newType: string): void { this.employeeType = createEmployeeType(newType); }
bonus(monthlySalary: number): number { return this.employeeType.bonus(monthlySalary); }}
const zhang = new Employee("张伟", "engineer");console.log(zhang.type, zhang.bonus(20000));zhang.promote("manager");console.log(zhang.type, zhang.bonus(20000));
// 输出:// engineer 20000// manager 60000import unittest
from target import Employee
class PromoteTest(unittest.TestCase): def test_bonus_follows_current_type(self): zhang = Employee("张伟", "engineer") self.assertEqual(zhang.bonus(20000), 20000) zhang.promote("manager") self.assertEqual((zhang.type, zhang.bonus(20000)), ("manager", 60000))
def test_unknown_type_is_rejected(self): with self.assertRaisesRegex(ValueError, "未知的员工类型:intern"): Employee("张伟", "engineer").promote("intern")
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { Employee } from "./target";
test("奖金跟着当前类型走", () => { const zhang = new Employee("张伟", "engineer"); assert.equal(zhang.bonus(20000), 20000); zhang.promote("manager"); assert.deepEqual([zhang.type, zhang.bonus(20000)], ["manager", 60000]);});
test("拒绝未知类型", () => { assert.throws(() => new Employee("张伟", "engineer").promote("intern"), /未知的员工类型:intern/);});Employee 本身没有子类,升职时它还是同一个对象,名字、入职记录都在;变的只是它持有的那个 EmployeeType。按类型分支的逻辑同样住进了子类,只不过是类型对象的子类。
相关的设计模式
Section titled “相关的设计模式”间接继承的结果就是状态(State)模式:Employee 是环境(Context),EmployeeType 是状态接口,EngineerType、ManagerType、SalesmanType 是具体状态,promote 就是状态切换。行为随状态变化,而对象的身份不变。状态模式见《设计模式》书第 21 章。
如果类型对象不是随业务流程切换,而是从外部选定、注入进来的一种算法,比如奖金规则由配置决定,同样的结构就叫策略(Strategy)模式,见《设计模式》书第 17 章。两者的代码形状几乎一样,区别在意图:状态模式里对象会自己从一个状态走到另一个状态,策略模式里策略通常由使用者挑好了交进来。直接子类化的版本不对应某个特定模式,它就是第 38 章以多态取代条件表达式的前置步骤。
真实系统里的例子
Section titled “真实系统里的例子”Python 的通知渠道
Section titled “Python 的通知渠道”后台系统发通知,渠道有邮件、短信、站内信,常见的写法是一个 Notifier 带一个 channel 字段,发送、计费、长度限制都按它分支。下面把它换成每个渠道一个子类,notifier_for 是唯一按渠道分支的地方,用列表模拟发出去的消息。
"""通知渠道:用子类取代 channel 字段。"""from abc import ABC, abstractmethod
OUTBOX: list[str] = []
class NotifierV1: """重构前:一个类,按 channel 分支。"""
def __init__(self, channel: str) -> None: self.channel = channel
def send(self, to: str, text: str) -> None: if self.channel == "sms": text = text[:7] prefix = {"email": "邮件", "sms": "短信", "inbox": "站内信"}[self.channel] OUTBOX.append(f"{prefix} -> {to}:{text}")
class Notifier(ABC): """重构后:每个渠道一个子类。"""
label = ""
def send(self, to: str, text: str) -> None: OUTBOX.append(f"{self.label} -> {to}:{self.shape(text)}")
@abstractmethod def shape(self, text: str) -> str: ...
class EmailNotifier(Notifier): label = "邮件"
def shape(self, text: str) -> str: return text
class SmsNotifier(Notifier): label = "短信"
def shape(self, text: str) -> str: return text[:7]
class InboxNotifier(Notifier): label = "站内信"
def shape(self, text: str) -> str: return text
def notifier_for(channel: str) -> Notifier: return {"email": EmailNotifier, "sms": SmsNotifier, "inbox": InboxNotifier}[channel]()
if __name__ == "__main__": text = "您的订单已发货,请注意查收" for channel in ("email", "sms", "inbox"): NotifierV1(channel).send("小周", text) before = list(OUTBOX) OUTBOX.clear() for channel in ("email", "sms", "inbox"): notifier_for(channel).send("小周", text) print(before == OUTBOX) print(OUTBOX[1])
# 输出:# True# 短信 -> 小周:您的订单已发货短信的截断规则现在只在 SmsNotifier 里。要加企业微信渠道,写一个 WeComNotifier,在 notifier_for 的字典里加一项,不用碰其他渠道的代码。Django 的邮件后端、日志模块的各种 Handler 都是这样按“种类”拆成子类的。
TS 的图形类型
Section titled “TS 的图形类型”前端画图的代码里,图形常常是带 kind 字段的对象字面量,面积、描述各写一个 switch。下面把它们换成类。
// 图形:用子类取代 kind 字段。
// 重构前:对象字面量加 switch。interface ShapeData { kind: string; size: number;}
function areaV1(s: ShapeData): number { switch (s.kind) { case "circle": return Math.PI * s.size * s.size; case "square": return s.size * s.size; default: throw new Error(`未知图形:${s.kind}`); }}
function describeV1(s: ShapeData): string { return `${s.kind === "circle" ? "圆" : "正方形"} 面积 ${areaV1(s).toFixed(2)}`;}
// 重构后:每种图形一个类。abstract class Shape { constructor(readonly size: number) {} abstract readonly name: string; abstract area(): number;
describe(): string { return `${this.name} 面积 ${this.area().toFixed(2)}`; }}
class Circle extends Shape { readonly name = "圆";
area(): number { return Math.PI * this.size * this.size; }}
class Square extends Shape { readonly name = "正方形";
area(): number { return this.size * this.size; }}
function shapeFrom(data: ShapeData): Shape { switch (data.kind) { case "circle": return new Circle(data.size); case "square": return new Square(data.size); default: throw new Error(`未知图形:${data.kind}`); }}
const data: ShapeData[] = [ { kind: "circle", size: 1 }, { kind: "square", size: 3 },];console.log(data.every((d) => describeV1(d) === shapeFrom(d).describe()));for (const d of data) console.log(shapeFrom(d).describe());
// 输出:// true// 圆 面积 3.14// 正方形 面积 9.00TS 里还有一条路:把 kind 写成字面量联合类型(可辨识联合,discriminated union),switch 里漏写一种情况时编译器会报错。图形种类固定、操作经常增加时,可辨识联合更合适,加一个操作就是加一个函数;种类经常增加、操作相对固定时,子类更合适,加一个种类就是加一个类。这是同一个取舍的两面。
什么时候别用
Section titled “什么时候别用”类型字段只在一两处被判断、而且分支里只有一行代码时,不值得建一组子类。一个 {"engineer": "工程师", ...} 字典比三个各含一行代码的类更容易读,也更容易改。判断标准是重复:同一组分支出现在几个地方?每个分支里有多少逻辑?两处以上、每处不止一行,子类才开始划算。
类型会变、或者已经按别的维度有了子类时,不要硬上直接子类化。为了支持升职去写“复制旧对象的所有字段、新建一个 Manager、再把所有引用换过去”的代码,比原来的 match 危险得多,用前面讲的间接继承。
第三种情况是类型的种类由运营在后台配置,今天三种明天五种,而每种的差别只是几个参数(奖金倍数、头衔文字)。这时差别是数据而不是行为,该做的是把参数存进一张配置表,用一个类读表,而不是每加一种类型就发一次版。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 以子类取代类型码关心什么 |
|---|---|---|
| 以多态取代条件表达式(第 38 章) | 把已有的条件分支搬进子类 | 先让子类出现;通常紧接着做第 38 章 |
| 移除子类(第 53 章) | 子类已经没什么差别,换回字段 | 反向手法:类型字段驱动了太多分支,换成子类 |
| 以对象取代基本类型(第 15 章) | 给一个值加上校验和行为,变成值对象 | 让整个对象的行为随类型不同;间接继承时两者会合流 |
下面的票务代码按 kind 分支算票价和显示票种。用以子类取代类型码把它改成 AdultTicket、ChildTicket、SeniorTicket 三个子类,create_ticket 是唯一按 kind 分支的地方。
"""练习:票种。"""
class Ticket: def __init__(self, kind: str) -> None: self.kind = kind
def price(self, base: int) -> float: if self.kind == "child": return base * 50 / 100 if self.kind == "senior": return base * 60 / 100 return float(base)
def label(self) -> str: if self.kind == "child": return "儿童票" if self.kind == "senior": return "长者票" return "成人票"
def create_ticket(kind: str) -> Ticket: return Ticket(kind)
if __name__ == "__main__": for kind in ("adult", "child", "senior"): ticket = create_ticket(kind) print(ticket.label(), f"{ticket.price(80):.2f}")
# 输出:# 成人票 80.00# 儿童票 40.00# 长者票 48.00import unittest
from target import create_ticket
class TicketTest(unittest.TestCase): def test_labels(self): labels = [create_ticket(k).label() for k in ("adult", "child", "senior")] self.assertEqual(labels, ["成人票", "儿童票", "长者票"])
def test_prices(self): prices = [create_ticket(k).price(80) for k in ("adult", "child", "senior")] self.assertEqual(prices, [80, 40, 48])
def test_kind_is_readable(self): self.assertEqual(create_ticket("child").kind, "child")
if __name__ == "__main__": unittest.main()// 练习:票种。export class Ticket { constructor(readonly kind: string) {}
price(base: number): number { if (this.kind === "child") return (base * 50) / 100; if (this.kind === "senior") return (base * 60) / 100; return base; }
label(): string { if (this.kind === "child") return "儿童票"; if (this.kind === "senior") return "长者票"; return "成人票"; }}
export function createTicket(kind: string): Ticket { return new Ticket(kind);}
for (const kind of ["adult", "child", "senior"]) { const ticket = createTicket(kind); console.log(ticket.label(), ticket.price(80).toFixed(2));}
// 输出:// 成人票 80.00// 儿童票 40.00// 长者票 48.00import test from "node:test";import assert from "node:assert/strict";import { createTicket } from "./target";
const KINDS = ["adult", "child", "senior"];
test("票种名称", () => { assert.deepEqual(KINDS.map((k) => createTicket(k).label()), ["成人票", "儿童票", "长者票"]);});
test("票价", () => { assert.deepEqual(KINDS.map((k) => createTicket(k).price(80)), [80, 40, 48]);});
test("票种字段照样能读", () => { assert.equal(createTicket("child").kind, "child");});- 同一个类型字段在多个方法里驱动分支,就是以子类取代类型码的信号;每种类型一个子类,该类型的规则住在一起。
- 先用工厂函数收拢创建入口,因为只有工厂函数能按类型返回不同的子类;最后全系统只剩工厂里一个分支。
- 去掉默认分支、在父类声明抽象方法,“加了类型忘了改某处”就从静默出错变成实例化或编译时报错。
- 类型会变、或者已经按别的维度分过子类时,用间接继承:给类型建类层次,对象持有类型对象,这就是状态模式。
- 分支少、每支一行、或差别只是配置数据时,别建子类。