以多态取代条件表达式
以多态取代条件表达式(Replace Conditional with Polymorphism)把“按类型分支”的 switch 或 match 拆开,每种类型一个子类,每个分支变成对应子类里的一个方法。 它解决的痛是:同一套按类型的分支出现在好几个函数里,加一种类型就得把这些函数挨个找出来改,漏一个就是 bug。
小吴在做一个儿童科普 App 里的“鸟类图鉴”。每种鸟有一张卡片,卡片上显示羽毛状态和飞行速度。欧洲燕子羽毛一般、时速 35 公里;非洲燕子要看它叼了几个椰子,椰子越多越累、飞得越慢;挪威蓝鹦鹉要看它身上的电压,电压太高羽毛会烧焦,如果被钉在架子上就飞不动。鸟的数据从后台接口拿来,是一个个字典,type 字段说明是哪种鸟。
"""鸟类图鉴:重构前。"""
def plumage(bird: dict) -> str: match bird["type"]: case "european_swallow": return "一般" case "african_swallow": return "疲惫" if bird["number_of_coconuts"] > 2 else "一般" case "norwegian_blue_parrot": return "焦黑" if bird["voltage"] > 100 else "漂亮" case _: return "未知"
def air_speed(bird: dict) -> float | None: match bird["type"]: case "european_swallow": return 35 case "african_swallow": return 40 - 2 * bird["number_of_coconuts"] case "norwegian_blue_parrot": return 0 if bird["is_nailed"] else 10 + bird["voltage"] / 10 case _: return None
def plumages(birds: list[dict]) -> dict[str, str]: return {b["name"]: plumage(b) for b in birds}
def speeds(birds: list[dict]) -> dict[str, float | None]: return {b["name"]: air_speed(b) for b in birds}
BIRDS = [ {"name": "小欧", "type": "european_swallow"}, {"name": "小非", "type": "african_swallow", "number_of_coconuts": 3}, {"name": "蓝蓝", "type": "norwegian_blue_parrot", "voltage": 200, "is_nailed": False}, {"name": "渡渡", "type": "dodo"},]
if __name__ == "__main__": looks, speed_of = plumages(BIRDS), speeds(BIRDS) for name, look in looks.items(): speed = speed_of[name] text = "未知" if speed is None else f"{speed:g} 公里/小时" print(f"{name}:羽毛{look},速度 {text}")
# 输出:# 小欧:羽毛一般,速度 35 公里/小时# 小非:羽毛疲惫,速度 34 公里/小时# 蓝蓝:羽毛焦黑,速度 30 公里/小时# 渡渡:羽毛未知,速度 未知// 鸟类图鉴:重构前。export interface BirdData { name: string; type: string; numberOfCoconuts?: number; voltage?: number; isNailed?: boolean;}
function plumage(bird: BirdData): string { switch (bird.type) { case "european_swallow": return "一般"; case "african_swallow": return (bird.numberOfCoconuts ?? 0) > 2 ? "疲惫" : "一般"; case "norwegian_blue_parrot": return (bird.voltage ?? 0) > 100 ? "焦黑" : "漂亮"; default: return "未知"; }}
function airSpeed(bird: BirdData): number | null { switch (bird.type) { case "european_swallow": return 35; case "african_swallow": return 40 - 2 * (bird.numberOfCoconuts ?? 0); case "norwegian_blue_parrot": return bird.isNailed ? 0 : 10 + (bird.voltage ?? 0) / 10; default: return null; }}
export function plumages(birds: BirdData[]): Record<string, string> { return Object.fromEntries(birds.map((b) => [b.name, plumage(b)]));}
export function speeds(birds: BirdData[]): Record<string, number | null> { return Object.fromEntries(birds.map((b) => [b.name, airSpeed(b)]));}
export const BIRDS: BirdData[] = [ { name: "小欧", type: "european_swallow" }, { name: "小非", type: "african_swallow", numberOfCoconuts: 3 }, { name: "蓝蓝", type: "norwegian_blue_parrot", voltage: 200, isNailed: false }, { name: "渡渡", type: "dodo" },];
const looks = plumages(BIRDS);const speedOf = speeds(BIRDS);for (const [name, look] of Object.entries(looks)) { const speed = speedOf[name]; const text = speed === null ? "未知" : `${speed} 公里/小时`; console.log(`${name}:羽毛${look},速度 ${text}`);}
// 输出:// 小欧:羽毛一般,速度 35 公里/小时// 小非:羽毛疲惫,速度 34 公里/小时// 蓝蓝:羽毛焦黑,速度 30 公里/小时// 渡渡:羽毛未知,速度 未知测试覆盖三种鸟各自的分支,以及未知的鸟:
import unittest
from target import BIRDS, plumages, speeds
class BirdsTest(unittest.TestCase): def test_plumages(self): self.assertEqual(plumages(BIRDS), {"小欧": "一般", "小非": "疲惫", "蓝蓝": "焦黑", "渡渡": "未知"})
def test_speeds(self): self.assertEqual(speeds(BIRDS), {"小欧": 35, "小非": 34, "蓝蓝": 30, "渡渡": None})
def test_light_african_swallow(self): bird = {"name": "轻轻", "type": "african_swallow", "number_of_coconuts": 1} self.assertEqual((plumages([bird])["轻轻"], speeds([bird])["轻轻"]), ("一般", 38))
def test_nailed_parrot(self): bird = {"name": "钉钉", "type": "norwegian_blue_parrot", "voltage": 50, "is_nailed": True} self.assertEqual((plumages([bird])["钉钉"], speeds([bird])["钉钉"]), ("漂亮", 0))
if __name__ == "__main__": unittest.main()import test from "node:test";import assert from "node:assert/strict";import { BIRDS, plumages, speeds, type BirdData } from "./target";
test("羽毛", () => { assert.deepEqual(plumages(BIRDS), { 小欧: "一般", 小非: "疲惫", 蓝蓝: "焦黑", 渡渡: "未知" });});
test("速度", () => { assert.deepEqual(speeds(BIRDS), { 小欧: 35, 小非: 34, 蓝蓝: 30, 渡渡: null });});
test("椰子少的非洲燕子", () => { const bird: BirdData = { name: "轻轻", type: "african_swallow", numberOfCoconuts: 1 }; assert.deepEqual([plumages([bird])["轻轻"], speeds([bird])["轻轻"]], ["一般", 38]);});
test("被钉住的鹦鹉", () => { const bird: BirdData = { name: "钉钉", type: "norwegian_blue_parrot", voltage: 50, isNailed: true }; assert.deepEqual([plumages([bird])["钉钉"], speeds([bird])["钉钉"]], ["漂亮", 0]);});如果现在要加一个需求:图鉴里加入“帝企鹅”,羽毛“光亮”,速度为 0(企鹅不会飞)。
小吴要在 plumage 的 match 里加一个 case,再在 air_speed 的 match 里加一个 case。现在只有两个函数,可图鉴后面还要加“叫声”“栖息地”“食性”,每加一个属性就多一个按 type 分支的函数。到那时加一种鸟要改五个函数,而这五个函数不一定写在同一个文件里。漏改一个不会报错:Python 的 case _ 和 TS 的 default 会默默接住新类型,返回“未知”或 None,企鹅的卡片上就会出现“叫声:未知”。
第二个问题是“一种鸟的规则”被拆散了。非洲燕子的两条规则(椰子多了羽毛疲惫、每个椰子减速 2 公里)分在两个函数里,中间隔着欧洲燕子和鹦鹉的代码。要回答“非洲燕子的行为有哪些”,得把每个函数里 african_swallow 那一支挑出来拼在一起。而产品经理恰恰是按鸟提需求的:“非洲燕子的规则要改一下”。
第三个问题是数据字段。number_of_coconuts 只对非洲燕子有意义,voltage 和 is_nailed 只对鹦鹉有意义,可它们都挂在同一个字典上,TS 版只能把它们全标成可选字段,再到处写 ?? 0。类型系统帮不了忙:给欧洲燕子传一个 voltage 也不会报错。
用第 2 章的名字说,这是重复的 switch:同一个按 type 的分支结构,在每个关心鸟的函数里各抄一份。
每种鸟一个子类,共同的父类 Bird 给出默认行为,工厂函数 create_bird 根据 type 选子类。
"""鸟类图鉴:重构后。"""
class Bird: def __init__(self, data: dict) -> None: self.data = data
@property def name(self) -> str: return self.data["name"]
def plumage(self) -> str: return "未知"
def air_speed(self) -> float | None: return None
class EuropeanSwallow(Bird): def plumage(self) -> str: return "一般"
def air_speed(self) -> float | None: return 35
class AfricanSwallow(Bird): def plumage(self) -> str: return "疲惫" if self.data["number_of_coconuts"] > 2 else "一般"
def air_speed(self) -> float | None: return 40 - 2 * self.data["number_of_coconuts"]
class NorwegianBlueParrot(Bird): def plumage(self) -> str: return "焦黑" if self.data["voltage"] > 100 else "漂亮"
def air_speed(self) -> float | None: return 0 if self.data["is_nailed"] else 10 + self.data["voltage"] / 10
def create_bird(data: dict) -> Bird: match data["type"]: case "european_swallow": return EuropeanSwallow(data) case "african_swallow": return AfricanSwallow(data) case "norwegian_blue_parrot": return NorwegianBlueParrot(data) case _: return Bird(data)
def plumages(birds: list[dict]) -> dict[str, str]: return {bird.name: bird.plumage() for bird in map(create_bird, birds)}
def speeds(birds: list[dict]) -> dict[str, float | None]: return {bird.name: bird.air_speed() for bird in map(create_bird, birds)}
BIRDS = [ {"name": "小欧", "type": "european_swallow"}, {"name": "小非", "type": "african_swallow", "number_of_coconuts": 3}, {"name": "蓝蓝", "type": "norwegian_blue_parrot", "voltage": 200, "is_nailed": False}, {"name": "渡渡", "type": "dodo"},]
if __name__ == "__main__": looks, speed_of = plumages(BIRDS), speeds(BIRDS) for name, look in looks.items(): speed = speed_of[name] text = "未知" if speed is None else f"{speed:g} 公里/小时" print(f"{name}:羽毛{look},速度 {text}")
# 输出:# 小欧:羽毛一般,速度 35 公里/小时# 小非:羽毛疲惫,速度 34 公里/小时# 蓝蓝:羽毛焦黑,速度 30 公里/小时# 渡渡:羽毛未知,速度 未知// 鸟类图鉴:重构后。export interface BirdData { name: string; type: string; numberOfCoconuts?: number; voltage?: number; isNailed?: boolean;}
class Bird { constructor(protected readonly data: BirdData) {}
get name(): string { return this.data.name; }
plumage(): string { return "未知"; }
airSpeed(): number | null { return null; }}
class EuropeanSwallow extends Bird { plumage(): string { return "一般"; }
airSpeed(): number | null { return 35; }}
class AfricanSwallow extends Bird { private get coconuts(): number { return this.data.numberOfCoconuts ?? 0; }
plumage(): string { return this.coconuts > 2 ? "疲惫" : "一般"; }
airSpeed(): number | null { return 40 - 2 * this.coconuts; }}
class NorwegianBlueParrot extends Bird { private get voltage(): number { return this.data.voltage ?? 0; }
plumage(): string { return this.voltage > 100 ? "焦黑" : "漂亮"; }
airSpeed(): number | null { return this.data.isNailed ? 0 : 10 + this.voltage / 10; }}
function createBird(data: BirdData): Bird { switch (data.type) { case "european_swallow": return new EuropeanSwallow(data); case "african_swallow": return new AfricanSwallow(data); case "norwegian_blue_parrot": return new NorwegianBlueParrot(data); default: return new Bird(data); }}
export function plumages(birds: BirdData[]): Record<string, string> { return Object.fromEntries(birds.map(createBird).map((b) => [b.name, b.plumage()]));}
export function speeds(birds: BirdData[]): Record<string, number | null> { return Object.fromEntries(birds.map(createBird).map((b) => [b.name, b.airSpeed()]));}
export const BIRDS: BirdData[] = [ { name: "小欧", type: "european_swallow" }, { name: "小非", type: "african_swallow", numberOfCoconuts: 3 }, { name: "蓝蓝", type: "norwegian_blue_parrot", voltage: 200, isNailed: false }, { name: "渡渡", type: "dodo" },];
const looks = plumages(BIRDS);const speedOf = speeds(BIRDS);for (const [name, look] of Object.entries(looks)) { const speed = speedOf[name]; const text = speed === null ? "未知" : `${speed} 公里/小时`; console.log(`${name}:羽毛${look},速度 ${text}`);}
// 输出:// 小欧:羽毛一般,速度 35 公里/小时// 小非:羽毛疲惫,速度 34 公里/小时// 蓝蓝:羽毛焦黑,速度 30 公里/小时// 渡渡:羽毛未知,速度 未知输出不变,四个测试一个字没改,照样通过。两张图对比一下结构。
加帝企鹅,现在是新写一个 EmperorPenguin 子类,实现 plumage 和 air_speed,再在 create_bird 里加一个 case。以后加了“叫声”“栖息地”,它们是 Bird 上的新方法,加新鸟时写子类的人会看到父类有哪些方法要覆盖。按 type 分支的地方从“每个属性一处”变成了“整个程序只有一处”,就是工厂函数。条件没有消失,但它不再随属性的数量增长。
一种鸟的规则现在住在一起。非洲燕子的两条规则都在 AfricanSwallow 里,产品经理说“非洲燕子要改”,打开这一个类就够了。
字段的问题部分解决了。TS 版里 AfricanSwallow 用一个私有的 coconuts 读取器把 ?? 0 收在一处,鹦鹉也一样。更彻底的做法是让每个子类的构造函数只接收自己需要的字段,那属于第 52 章以子类取代类型码的范围,这里的数据仍从同一个接口字典来,先保持这样。
怎么一步步做
Section titled “怎么一步步做”每一步之后跑测试。
- 如果还没有类,先建一个。这里新建
Bird,构造函数接收原来的字典,再写一个工厂函数create_bird,暂时总是返回Bird(data)。让plumages和speeds改成先create_bird(b)再处理(这一步用到第 10 章函数组合成类和第 48 章以工厂函数取代构造函数)。 - 把带条件的函数搬进
Bird成为方法:plumage(bird)变成Bird.plumage(self),函数体原样搬进去,bird[...]改成self.data[...]。调用处改成bird.plumage()。这是第 21 章的搬移函数(IDE 可自动完成部分)。 - 为每种类型建一个空子类,
EuropeanSwallow(Bird)等,在create_bird里按type返回对应子类。此时子类什么都没覆盖,行为不变。 - 挑一个子类,在里面覆盖
plumage,把父类 match 里对应的那一支复制过来。跑测试。然后把父类 match 里这一支删掉,或者改成抛异常,确认它再也走不到。跑测试。 - 对每个子类、每个带条件的方法重复第 4 步,一次一支。
- 父类的方法里只剩默认分支。把它改成默认实现(这里返回“未知”和
None),如果所有类型都有子类、没有合理的默认值,就让父类方法抛出NotImplementedError,或者在 TS 里把父类声明成abstract。
第 4 步“先复制、跑测试、再删”看起来啰嗦,但它让每一步都可以单独回退。一次把整个 match 拆光,测试挂了你不知道是哪一支搬错了。
另一种常见的形状是:大部分逻辑对所有情况都一样,只有某一类有特殊变体。这时不需要每种类型一个子类,只需要“通用版”和“特殊版”两个类,特殊版只覆盖不一样的部分。下面是一家航运公司给航程评级的代码:评级由利润系数、航程风险、船长历史风险算出来;如果航线去中国、船长以前也去过中国,有一套特殊的加减分规则,散在两个函数的 if 里。
"""航程评级:重构前。"""
def has_china(history: list[dict]) -> bool: return any(v["zone"] == "中国" for v in history)
def voyage_risk(voyage: dict) -> int: result = 1 if voyage["length"] > 4: result += 2 if voyage["length"] > 8: result += voyage["length"] - 8 if voyage["zone"] in ("中国", "东印度"): result += 4 return max(result, 0)
def captain_history_risk(voyage: dict, history: list[dict]) -> int: result = 1 if len(history) < 5: result += 4 result += len([v for v in history if v["profit"] < 0]) if voyage["zone"] == "中国" and has_china(history): result -= 2 return max(result, 0)
def voyage_profit_factor(voyage: dict, history: list[dict]) -> int: result = 2 if voyage["zone"] == "中国": result += 1 if voyage["zone"] == "东印度": result += 1 if voyage["zone"] == "中国" and has_china(history): result += 3 if len(history) > 10: result += 1 if voyage["length"] > 12: result += 1 if voyage["length"] > 18: result -= 1 else: if len(history) > 8: result += 1 if voyage["length"] > 14: result -= 1 return result
def rating(voyage: dict, history: list[dict]) -> str: vpf = voyage_profit_factor(voyage, history) vr = voyage_risk(voyage) chr_ = captain_history_risk(voyage, history) return "A" if vpf * 3 > vr + chr_ * 2 else "B"
HISTORY = [ {"zone": "东印度", "profit": 5}, {"zone": "西印度", "profit": 15}, {"zone": "中国", "profit": -2}, {"zone": "西非", "profit": 7},]NO_CHINA = [v for v in HISTORY if v["zone"] != "中国"]
if __name__ == "__main__": print(rating({"zone": "西印度", "length": 10}, HISTORY)) print(rating({"zone": "中国", "length": 10}, HISTORY)) print(rating({"zone": "中国", "length": 10}, NO_CHINA))
# 输出:# B# A# Bvoyage["zone"] == "中国" and has_china(history) 这个条件出现了两次,每次后面跟着一组特殊规则。重构后,通用规则放在 Rating,特殊规则放在它的子类 ExperiencedChinaRating,子类只覆盖不一样的那几个方法。为了让子类能只替换“航程长度”和“历史长度”这两小块,先把 voyage_profit_factor 里的 else 分支提成了两个方法 voyage_length_factor 和 history_length_factor。
"""航程评级:重构后。"""
class Rating: def __init__(self, voyage: dict, history: list[dict]) -> None: self.voyage = voyage self.history = history
@property def value(self) -> str: vpf = self.voyage_profit_factor() vr = self.voyage_risk() chr_ = self.captain_history_risk() return "A" if vpf * 3 > vr + chr_ * 2 else "B"
def voyage_risk(self) -> int: result = 1 if self.voyage["length"] > 4: result += 2 if self.voyage["length"] > 8: result += self.voyage["length"] - 8 if self.voyage["zone"] in ("中国", "东印度"): result += 4 return max(result, 0)
def captain_history_risk(self) -> int: result = 1 if len(self.history) < 5: result += 4 result += len([v for v in self.history if v["profit"] < 0]) return max(result, 0)
def voyage_profit_factor(self) -> int: result = 2 if self.voyage["zone"] == "中国": result += 1 if self.voyage["zone"] == "东印度": result += 1 return result + self.history_length_factor() + self.voyage_length_factor()
def history_length_factor(self) -> int: return 1 if len(self.history) > 8 else 0
def voyage_length_factor(self) -> int: return -1 if self.voyage["length"] > 14 else 0
class ExperiencedChinaRating(Rating): def captain_history_risk(self) -> int: return max(super().captain_history_risk() - 2, 0)
def voyage_profit_factor(self) -> int: return super().voyage_profit_factor() + 3
def history_length_factor(self) -> int: return 1 if len(self.history) > 10 else 0
def voyage_length_factor(self) -> int: result = 0 if self.voyage["length"] > 12: result += 1 if self.voyage["length"] > 18: result -= 1 return result
def create_rating(voyage: dict, history: list[dict]) -> Rating: if voyage["zone"] == "中国" and any(v["zone"] == "中国" for v in history): return ExperiencedChinaRating(voyage, history) return Rating(voyage, history)
def rating(voyage: dict, history: list[dict]) -> str: return create_rating(voyage, history).value
HISTORY = [ {"zone": "东印度", "profit": 5}, {"zone": "西印度", "profit": 15}, {"zone": "中国", "profit": -2}, {"zone": "西非", "profit": 7},]NO_CHINA = [v for v in HISTORY if v["zone"] != "中国"]
if __name__ == "__main__": print(rating({"zone": "西印度", "length": 10}, HISTORY)) print(rating({"zone": "中国", "length": 10}, HISTORY)) print(rating({"zone": "中国", "length": 10}, NO_CHINA))
# 输出:# B# A# Bimport unittest
from target import HISTORY, NO_CHINA, rating
LONG_CHINA_HISTORY = [{"zone": "中国", "profit": 1}] * 11
class RatingTest(unittest.TestCase): def test_ordinary_voyage(self): self.assertEqual(rating({"zone": "西印度", "length": 10}, HISTORY), "B")
def test_china_with_china_experience(self): self.assertEqual(rating({"zone": "中国", "length": 10}, HISTORY), "A")
def test_china_without_china_experience(self): self.assertEqual(rating({"zone": "中国", "length": 10}, NO_CHINA), "B")
def test_long_voyages(self): self.assertEqual(rating({"zone": "中国", "length": 20}, LONG_CHINA_HISTORY), "A") self.assertEqual(rating({"zone": "西非", "length": 16}, LONG_CHINA_HISTORY), "B")
if __name__ == "__main__": unittest.main()ExperiencedChinaRating 的每个方法都在说“和通用规则比,哪里不一样”:历史风险减 2,利润系数加 3,两个长度系数换一套阈值。读通用规则的人不用再跳过一个个 if voyage["zone"] == "中国" and has_china(history);改中国规则的人只看子类。这个例子只给了 Python 版,TS 的写法与鸟类的例子同理。
定义与适用场景
Section titled “定义与适用场景”以多态取代条件表达式:把条件表达式的每个分支放进一个子类里覆盖的方法中,让对象的类型决定执行哪个分支,并把原来的方法改成父类的默认实现或抽象方法。
它最适合两种情况。第一种是本章的鸟:一组函数都按同一个类型字段分支,类型的数量会增长。判断信号是第 2 章说的重复的 switch,同一套 case 出现在第二个函数里时,就该考虑了。第二种是航程评级:有一个基础逻辑和一个变体,变体的差异分散在多个函数的 if 里。用子类表达变体,读者能一眼看出变体和基础逻辑差在哪里。
不是所有条件都该变成多态。只出现一次的 switch、类型数量固定且很少变化的分支(比如 if is_weekend),用多态反而让代码分散到好几个类里,读者要跳来跳去。Python 3.10 的 match 和 TS 的可辨识联合(discriminated union)加穷尽检查,也是对付这类问题的另一种办法:TS 里给 type 字段定义成字面量联合类型,switch 漏了一个 case 编译器就会报错,这能解决“漏改不报错”,但解决不了“一种类型的规则散落在多个函数里”。
相关的设计模式
Section titled “相关的设计模式”重构之后的鸟类结构就是策略(Strategy)模式的形状:Bird 是策略的共同接口,三个子类是具体策略,plumages 和 speeds 是使用策略的上下文,它们只认 plumage() 和 air_speed() 这两个方法,不关心手里是哪种鸟。策略模式常见的写法是让上下文对象持有一个策略对象,而不是自己成为子类,第 56 章以委托取代子类讲的就是从继承走到这种持有的写法。策略模式见《设计模式》书第 17 章。
如果对象的“类型”会在运行时改变,比如订单从“待付款”变成“已发货”,各状态下 cancel()、refund() 的行为不同,那么按状态分支的条件换成多态之后,得到的是状态(State)模式:每个状态一个类,订单持有当前状态对象,状态变化时换掉这个对象。鸟不会从燕子变成鹦鹉,所以本章的例子是策略而不是状态。状态模式见《设计模式》书第 21 章。
真实系统里的例子
Section titled “真实系统里的例子”支付方式的分发
Section titled “支付方式的分发”电商的 TS 后端几乎都有按支付方式分支的代码:手续费怎么算、账单上显示什么、要不要跳转第三方页面。下面用最小的接口模拟,v1 是两个按 method 分支的函数,v2 是每种支付方式一个类,一张表把方式名映射到对象。
// 模拟电商后端:按支付方式算手续费和账单文案。type Method = "card" | "wallet" | "bank";
// 改造前:每个函数一套 switch。function feeV1(method: Method, amount: number): number { switch (method) { case "card": return amount * 0.02; case "wallet": return amount * 0.006; case "bank": return amount >= 1000 ? 0 : 5; }}
function labelV1(method: Method): string { switch (method) { case "card": return "信用卡"; case "wallet": return "电子钱包"; case "bank": return "银行转账"; }}
// 改造后:每种支付方式一个类,共同接口是 PaymentMethod。interface PaymentMethod { label: string; fee(amount: number): number;}
class Card implements PaymentMethod { label = "信用卡"; fee(amount: number): number { return amount * 0.02; }}
class Wallet implements PaymentMethod { label = "电子钱包"; fee(amount: number): number { return amount * 0.006; }}
class BankTransfer implements PaymentMethod { label = "银行转账"; fee(amount: number): number { return amount >= 1000 ? 0 : 5; }}
const METHODS: Record<Method, PaymentMethod> = { card: new Card(), wallet: new Wallet(), bank: new BankTransfer() };
function lineV1(method: Method, amount: number): string { return `${labelV1(method)} 手续费 ${feeV1(method, amount).toFixed(2)}`;}
function lineV2(method: Method, amount: number): string { const payment = METHODS[method]; return `${payment.label} 手续费 ${payment.fee(amount).toFixed(2)}`;}
const orders: [Method, number][] = [["card", 500], ["wallet", 500], ["bank", 500], ["bank", 1200]];console.log(orders.every(([m, a]) => lineV1(m, a) === lineV2(m, a)));for (const [m, a] of orders) console.log(lineV2(m, a));
// 输出:// true// 信用卡 手续费 10.00// 电子钱包 手续费 3.00// 银行转账 手续费 5.00// 银行转账 手续费 0.00Method 定义成字面量联合,v1 的 switch 已经有穷尽检查:加了 "crypto" 而忘了改 feeV1,编译器会说这个函数可能没有返回值。但改的地方仍然是每个函数一处。v2 里加一种支付方式是加一个类、表里加一行;Record<Method, PaymentMethod> 同样有穷尽检查,忘了在表里登记也会报错。真实系统里每个类还会有 redirectUrl()、refund() 这类方法,类的好处会随方法数量变大。
Python 的报表系统常有“导出为 CSV / JSON / Markdown”的功能。下面用标准库的 csv、json 模拟,export_v1 是按格式名分支的函数,v2 是一个 Exporter 父类和三个子类,父类定义统一的接口,每个子类只实现自己格式的那一部分。
"""模拟报表系统的导出功能。"""import csvimport ioimport json
ROWS = [{"城市": "杭州", "订单": 120}, {"城市": "成都", "订单": 95}]
def export_v1(rows: list[dict], fmt: str) -> str: """改造前:一个函数里按格式分支。""" if fmt == "csv": buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=list(rows[0]), lineterminator="\n") writer.writeheader() writer.writerows(rows) return buf.getvalue().rstrip("\n") elif fmt == "json": return json.dumps(rows, ensure_ascii=False) elif fmt == "markdown": header = "| " + " | ".join(rows[0]) + " |" lines = ["| " + " | ".join(str(v) for v in r.values()) + " |" for r in rows] return "\n".join([header, "|" + "---|" * len(rows[0]), *lines]) raise ValueError(f"不支持的格式:{fmt}")
class Exporter: """改造后:每种格式一个子类,调用方只认父类的接口。"""
def export(self, rows: list[dict]) -> str: return self.render(rows)
def render(self, rows: list[dict]) -> str: raise NotImplementedError
class CsvExporter(Exporter): def render(self, rows: list[dict]) -> str: buf = io.StringIO() writer = csv.DictWriter(buf, fieldnames=list(rows[0]), lineterminator="\n") writer.writeheader() writer.writerows(rows) return buf.getvalue().rstrip("\n")
class JsonExporter(Exporter): def render(self, rows: list[dict]) -> str: return json.dumps(rows, ensure_ascii=False)
class MarkdownExporter(Exporter): def render(self, rows: list[dict]) -> str: header = "| " + " | ".join(rows[0]) + " |" lines = ["| " + " | ".join(str(v) for v in r.values()) + " |" for r in rows] return "\n".join([header, "|" + "---|" * len(rows[0]), *lines])
EXPORTERS: dict[str, Exporter] = {"csv": CsvExporter(), "json": JsonExporter(), "markdown": MarkdownExporter()}
def export_v2(rows: list[dict], fmt: str) -> str: if fmt not in EXPORTERS: raise ValueError(f"不支持的格式:{fmt}") return EXPORTERS[fmt].export(rows)
if __name__ == "__main__": print(all(export_v1(ROWS, f) == export_v2(ROWS, f) for f in EXPORTERS)) print(export_v2(ROWS, "csv")) print(export_v2(ROWS, "markdown"))
# 输出:# True# 城市,订单# 杭州,120# 成都,95# | 城市 | 订单 |# |---|---|# | 杭州 | 120 |# | 成都 | 95 |export_v1 只有一个函数时,if 链其实还能接受。真正的压力来自第二个按格式分支的函数:下载时的文件扩展名、HTTP 响应的 Content-Type。到那一步,每个子类再加 extension 和 content_type 两个属性,而不是再写两条 if 链。Django 的序列化框架(django.core.serializers)就是这样组织的:每种格式一个 Serializer 子类,按名字在注册表里查找。
什么时候别用
Section titled “什么时候别用”只有一个函数按类型分支、类型也不太会增加时,别用。一个五行的 match 读起来一目了然,拆成一个父类加三个子类,读者要在四个类之间跳来跳去,才能拼出原来五行就说清的事。等第二个按同一类型分支的函数出现,再重构也不晚,那时你也更清楚哪些行为该放进类里。
分支依据不是“这个东西是什么”,而是“现在是什么情况”时,别用。if amount > 1000、if is_weekend 这类条件,值是连续的或者是临时状态,硬做成子类会得到 LargeAmountOrder 这种奇怪的类,而且对象的类型要随数据变化。这类条件该用第 35 章分解、第 37 章卫语句,而不是多态。
继承只能沿一个维度变化。如果鸟既要按种类变化,又要按“野生还是圈养”变化,用子类会得到种类乘以饲养方式那么多个类。这时考虑把其中一个维度做成委托对象,见第 56 章以委托取代子类。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 以多态取代条件表达式关心什么 |
|---|---|---|
| 以子类取代类型码(第 52 章) | 把类型字段本身变成子类,常是本章的前一步 | 把按类型分支的行为搬进子类 |
| 以委托取代子类(第 56 章) | 用持有的对象代替继承,以便多维度变化或运行时切换 | 用继承把分支变成覆盖的方法 |
| 分解条件表达式(第 35 章) | 给一个条件和分支起名字,条件仍在原处 | 把分支分散到各个类,调用处不再有条件 |
下面是游乐园售票系统的两个函数:票价和入园通道,都按票种分支。用本章手法改成每种票一个类,加一个工厂函数。改完测试应当照常通过,包括“未知票种报错”那个。
"""练习:游乐园门票。"""
def price(ticket: dict) -> int: match ticket["kind"]: case "adult": return 80 case "child": return 40 if ticket["age"] >= 6 else 0 case "senior": return 40 case _: raise ValueError(f"未知票种:{ticket['kind']}")
def gate(ticket: dict) -> str: match ticket["kind"]: case "adult": return "普通通道" case "child": return "家庭通道" case "senior": return "绿色通道" case _: raise ValueError(f"未知票种:{ticket['kind']}")
if __name__ == "__main__": for t in [{"kind": "adult"}, {"kind": "child", "age": 4}, {"kind": "senior"}]: print(f"{t['kind']}:{price(t)} 元,走{gate(t)}")
# 输出:# adult:80 元,走普通通道# child:0 元,走家庭通道# senior:40 元,走绿色通道import unittest
from target import gate, price
class TicketTest(unittest.TestCase): def test_adult(self): self.assertEqual((price({"kind": "adult"}), gate({"kind": "adult"})), (80, "普通通道"))
def test_child_by_age(self): self.assertEqual(price({"kind": "child", "age": 8}), 40) self.assertEqual(price({"kind": "child", "age": 4}), 0) self.assertEqual(gate({"kind": "child", "age": 4}), "家庭通道")
def test_senior(self): self.assertEqual((price({"kind": "senior"}), gate({"kind": "senior"})), (40, "绿色通道"))
def test_unknown_kind_raises(self): with self.assertRaises(ValueError): price({"kind": "vip"})
if __name__ == "__main__": unittest.main()// 练习:游乐园门票。export interface Ticket { kind: string; age?: number;}
export function price(ticket: Ticket): number { switch (ticket.kind) { case "adult": return 80; case "child": return (ticket.age ?? 0) >= 6 ? 40 : 0; case "senior": return 40; default: throw new Error(`未知票种:${ticket.kind}`); }}
export function gate(ticket: Ticket): string { switch (ticket.kind) { case "adult": return "普通通道"; case "child": return "家庭通道"; case "senior": return "绿色通道"; default: throw new Error(`未知票种:${ticket.kind}`); }}
for (const t of [{ kind: "adult" }, { kind: "child", age: 4 }, { kind: "senior" }]) { console.log(`${t.kind}:${price(t)} 元,走${gate(t)}`);}
// 输出:// adult:80 元,走普通通道// child:0 元,走家庭通道// senior:40 元,走绿色通道import test from "node:test";import assert from "node:assert/strict";import { gate, price } from "./target";
test("成人票", () => { assert.deepEqual([price({ kind: "adult" }), gate({ kind: "adult" })], [80, "普通通道"]);});
test("儿童票看年龄", () => { assert.equal(price({ kind: "child", age: 8 }), 40); assert.equal(price({ kind: "child", age: 4 }), 0); assert.equal(gate({ kind: "child", age: 4 }), "家庭通道");});
test("老人票", () => { assert.deepEqual([price({ kind: "senior" }), gate({ kind: "senior" })], [40, "绿色通道"]);});
test("未知票种报错", () => { assert.throws(() => price({ kind: "vip" }));});- 同一套按类型的分支出现在第二个函数里,就是以多态取代条件表达式的信号。
- 每种类型一个子类,分支变成覆盖的方法;按类型选择的条件只剩工厂函数里的一处。
- 一次搬一个分支:子类里先复制、跑测试,再删掉父类里的那一支。
- 只有一个变体时,用“通用类 + 变体子类”,子类只覆盖不一样的部分。
- 重构结果对应策略模式;类型会在运行时改变时,对应状态模式。