跳转到内容

以多态取代条件表达式

以多态取代条件表达式(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 公里/小时
// 渡渡:羽毛未知,速度 未知

输出不变,四个测试一个字没改,照样通过。两张图对比一下结构。

重构前:一个模块里的 plumage 和 air_speed 各自按 bird 字典的 type 分支

重构后:Bird 给出默认的 plumage 和 air_speed,三个子类各自覆盖这两个方法;create_bird 是唯一按 type 选择的地方

加帝企鹅,现在是新写一个 EmperorPenguin 子类,实现 plumage 和 air_speed,再在 create_bird 里加一个 case。以后加了“叫声”“栖息地”,它们是 Bird 上的新方法,加新鸟时写子类的人会看到父类有哪些方法要覆盖。按 type 分支的地方从“每个属性一处”变成了“整个程序只有一处”,就是工厂函数。条件没有消失,但它不再随属性的数量增长。

一种鸟的规则现在住在一起。非洲燕子的两条规则都在 AfricanSwallow 里,产品经理说“非洲燕子要改”,打开这一个类就够了。

字段的问题部分解决了。TS 版里 AfricanSwallow 用一个私有的 coconuts 读取器把 ?? 0 收在一处,鹦鹉也一样。更彻底的做法是让每个子类的构造函数只接收自己需要的字段,那属于第 52 章以子类取代类型码的范围,这里的数据仍从同一个接口字典来,先保持这样。

每一步之后跑测试。

  1. 如果还没有类,先建一个。这里新建 Bird,构造函数接收原来的字典,再写一个工厂函数 create_bird,暂时总是返回 Bird(data)。让 plumages 和 speeds 改成先 create_bird(b) 再处理(这一步用到第 10 章函数组合成类和第 48 章以工厂函数取代构造函数)。
  2. 把带条件的函数搬进 Bird 成为方法:plumage(bird) 变成 Bird.plumage(self),函数体原样搬进去,bird[...] 改成 self.data[...]。调用处改成 bird.plumage()。这是第 21 章的搬移函数(IDE 可自动完成部分)。
  3. 为每种类型建一个空子类,EuropeanSwallow(Bird) 等,在 create_bird 里按 type 返回对应子类。此时子类什么都没覆盖,行为不变。
  4. 挑一个子类,在里面覆盖 plumage,把父类 match 里对应的那一支复制过来。跑测试。然后把父类 match 里这一支删掉,或者改成抛异常,确认它再也走不到。跑测试。
  5. 对每个子类、每个带条件的方法重复第 4 步,一次一支。
  6. 父类的方法里只剩默认分支。把它改成默认实现(这里返回“未知”和 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
# B

voyage["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
# B
import 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 的写法与鸟类的例子同理。

以多态取代条件表达式:把条件表达式的每个分支放进一个子类里覆盖的方法中,让对象的类型决定执行哪个分支,并把原来的方法改成父类的默认实现或抽象方法。

它最适合两种情况。第一种是本章的鸟:一组函数都按同一个类型字段分支,类型的数量会增长。判断信号是第 2 章说的重复的 switch,同一套 case 出现在第二个函数里时,就该考虑了。第二种是航程评级:有一个基础逻辑和一个变体,变体的差异分散在多个函数的 if 里。用子类表达变体,读者能一眼看出变体和基础逻辑差在哪里。

不是所有条件都该变成多态。只出现一次的 switch、类型数量固定且很少变化的分支(比如 if is_weekend),用多态反而让代码分散到好几个类里,读者要跳来跳去。Python 3.10 的 match 和 TS 的可辨识联合(discriminated union)加穷尽检查,也是对付这类问题的另一种办法:TS 里给 type 字段定义成字面量联合类型,switch 漏了一个 case 编译器就会报错,这能解决“漏改不报错”,但解决不了“一种类型的规则散落在多个函数里”。

重构之后的鸟类结构就是策略(Strategy)模式的形状:Bird 是策略的共同接口,三个子类是具体策略,plumages 和 speeds 是使用策略的上下文,它们只认 plumage() 和 air_speed() 这两个方法,不关心手里是哪种鸟。策略模式常见的写法是让上下文对象持有一个策略对象,而不是自己成为子类,第 56 章以委托取代子类讲的就是从继承走到这种持有的写法。策略模式见《设计模式》书第 17 章。

如果对象的“类型”会在运行时改变,比如订单从“待付款”变成“已发货”,各状态下 cancel()、refund() 的行为不同,那么按状态分支的条件换成多态之后,得到的是状态(State)模式:每个状态一个类,订单持有当前状态对象,状态变化时换掉这个对象。鸟不会从燕子变成鹦鹉,所以本章的例子是策略而不是状态。状态模式见《设计模式》书第 21 章。

电商的 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.00

Method 定义成字面量联合,v1 的 switch 已经有穷尽检查:加了 "crypto" 而忘了改 feeV1,编译器会说这个函数可能没有返回值。但改的地方仍然是每个函数一处。v2 里加一种支付方式是加一个类、表里加一行;Record<Method, PaymentMethod> 同样有穷尽检查,忘了在表里登记也会报错。真实系统里每个类还会有 redirectUrl()、refund() 这类方法,类的好处会随方法数量变大。

Python 的报表系统常有“导出为 CSV / JSON / Markdown”的功能。下面用标准库的 csv、json 模拟,export_v1 是按格式名分支的函数,v2 是一个 Exporter 父类和三个子类,父类定义统一的接口,每个子类只实现自己格式的那一部分。

"""模拟报表系统的导出功能。"""
import csv
import io
import 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 子类,按名字在注册表里查找。

只有一个函数按类型分支、类型也不太会增加时,别用。一个五行的 match 读起来一目了然,拆成一个父类加三个子类,读者要在四个类之间跳来跳去,才能拼出原来五行就说清的事。等第二个按同一类型分支的函数出现,再重构也不晚,那时你也更清楚哪些行为该放进类里。

分支依据不是“这个东西是什么”,而是“现在是什么情况”时,别用。if amount > 1000、if is_weekend 这类条件,值是连续的或者是临时状态,硬做成子类会得到 LargeAmountOrder 这种奇怪的类,而且对象的类型要随数据变化。这类条件该用第 35 章分解、第 37 章卫语句,而不是多态。

继承只能沿一个维度变化。如果鸟既要按种类变化,又要按“野生还是圈养”变化,用子类会得到种类乘以饲养方式那么多个类。这时考虑把其中一个维度做成委托对象,见第 56 章以委托取代子类。

手法 它关心什么 以多态取代条件表达式关心什么
以子类取代类型码(第 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" }));
});
  • 同一套按类型的分支出现在第二个函数里,就是以多态取代条件表达式的信号。
  • 每种类型一个子类,分支变成覆盖的方法;按类型选择的条件只剩工厂函数里的一处。
  • 一次搬一个分支:子类里先复制、跑测试,再删掉父类里的那一支。
  • 只有一个变体时,用“通用类 + 变体子类”,子类只覆盖不一样的部分。
  • 重构结果对应策略模式;类型会在运行时改变时,对应状态模式。