跳转到内容

以委托取代子类 / 以委托取代超类

以委托取代子类(Replace Subclass with Delegate)把一个子类的特殊行为搬进一个单独的委托对象,原来的父类持有它;以委托取代超类(Replace Superclass with Delegate)把“继承一个父类”改成“持有一个它的实例”,只转发真正用得上的那几个方法。 它们解决的痛是:继承定下来就改不了,对象创建出来是什么子类就永远是什么子类,也只能沿一个方向分类;而继承一个父类会把它的全部成员连同以后加的成员一起接过来,用不上的也甩不掉。

小郑在剧院的售票系统里维护两块代码。第一块是订票:普通订票 Booking,还有一个子类 PremiumBooking 表示尊享订票,票价多一笔尊享费,演后谈总能参加,非高峰日还送晚餐。运营提出新需求:观众买了普通票之后,可以在演出前补差价升级成尊享。第二块是藏书馆的古卷管理:Scroll(一卷实物古卷)继承自 CatalogItem(目录里的一个条目),馆员发现改了目录里的书名,已经加载的古卷对象上还是旧名字。

"""以委托取代子类:重构前。"""
from dataclasses import dataclass
PEAK_DAYS = {"周五", "周六"}
@dataclass
class Show:
name: str
price: int
talkback: bool
@dataclass
class Extras:
premium_fee: int
dinner: bool
class Booking:
def __init__(self, show: Show, day: str) -> None:
self.show = show
self.day = day
def is_peak_day(self) -> bool:
return self.day in PEAK_DAYS
def has_talkback(self) -> bool:
return self.show.talkback and not self.is_peak_day()
def base_price(self) -> int:
result = self.show.price
if self.is_peak_day():
result += result * 15 // 100
return result
class PremiumBooking(Booking):
def __init__(self, show: Show, day: str, extras: Extras) -> None:
super().__init__(show, day)
self.extras = extras
def has_talkback(self) -> bool:
return self.show.talkback
def base_price(self) -> int:
return super().base_price() + self.extras.premium_fee
def has_dinner(self) -> bool:
return self.extras.dinner and not self.is_peak_day()
def create_booking(show: Show, day: str) -> Booking:
return Booking(show, day)
def create_premium_booking(show: Show, day: str, extras: Extras) -> PremiumBooking:
return PremiumBooking(show, day, extras)
def describe(b: Booking) -> str:
return f"{b.show.name} {b.day} 票价 {b.base_price()} 演后谈{'有' if b.has_talkback() else '无'}"
if __name__ == "__main__":
hamlet = Show("哈姆雷特", 200, True)
extras = Extras(premium_fee=80, dinner=True)
print(describe(create_booking(hamlet, "周三")))
print(describe(create_booking(hamlet, "周六")))
print(describe(create_premium_booking(hamlet, "周三", extras)))
print(describe(create_premium_booking(hamlet, "周六", extras)))
print(create_premium_booking(hamlet, "周三", extras).has_dinner())
# 输出:
# 哈姆雷特 周三 票价 200 演后谈有
# 哈姆雷特 周六 票价 230 演后谈无
# 哈姆雷特 周三 票价 280 演后谈有
# 哈姆雷特 周六 票价 310 演后谈有
# True
// 以委托取代子类:重构前。
const PEAK_DAYS = new Set(["周五", "周六"]);
export interface Show {
name: string;
price: number;
talkback: boolean;
}
export interface Extras {
premiumFee: number;
dinner: boolean;
}
export class Booking {
constructor(
readonly show: Show,
readonly day: string,
) {}
isPeakDay(): boolean {
return PEAK_DAYS.has(this.day);
}
hasTalkback(): boolean {
return this.show.talkback && !this.isPeakDay();
}
basePrice(): number {
let result = this.show.price;
if (this.isPeakDay()) result += Math.floor((result * 15) / 100);
return result;
}
}
export class PremiumBooking extends Booking {
constructor(
show: Show,
day: string,
readonly extras: Extras,
) {
super(show, day);
}
hasTalkback(): boolean {
return this.show.talkback;
}
basePrice(): number {
return super.basePrice() + this.extras.premiumFee;
}
hasDinner(): boolean {
return this.extras.dinner && !this.isPeakDay();
}
}
export function createBooking(show: Show, day: string): Booking {
return new Booking(show, day);
}
export function createPremiumBooking(show: Show, day: string, extras: Extras): PremiumBooking {
return new PremiumBooking(show, day, extras);
}
export function describe(b: Booking): string {
return `${b.show.name} ${b.day} 票价 ${b.basePrice()} 演后谈${b.hasTalkback() ? "有" : "无"}`;
}
const hamlet: Show = { name: "哈姆雷特", price: 200, talkback: true };
const extras: Extras = { premiumFee: 80, dinner: true };
console.log(describe(createBooking(hamlet, "周三")));
console.log(describe(createBooking(hamlet, "周六")));
console.log(describe(createPremiumBooking(hamlet, "周三", extras)));
console.log(describe(createPremiumBooking(hamlet, "周六", extras)));
console.log(createPremiumBooking(hamlet, "周三", extras).hasDinner());
// 输出:
// 哈姆雷特 周三 票价 200 演后谈有
// 哈姆雷特 周六 票价 230 演后谈无
// 哈姆雷特 周三 票价 280 演后谈有
// 哈姆雷特 周六 票价 310 演后谈有
// true

测试只通过两个工厂函数创建订票,所以子类是否存在对它们不可见:

import unittest
from target import Extras, Show, create_booking, create_premium_booking, describe
HAMLET = Show("哈姆雷特", 200, True)
EXTRAS = Extras(premium_fee=80, dinner=True)
class BookingTest(unittest.TestCase):
def test_regular_booking(self):
self.assertEqual(create_booking(HAMLET, "周三").base_price(), 200)
self.assertFalse(create_booking(HAMLET, "周六").has_talkback())
def test_premium_booking(self):
self.assertEqual(create_premium_booking(HAMLET, "周六", EXTRAS).base_price(), 310)
self.assertTrue(create_premium_booking(HAMLET, "周六", EXTRAS).has_talkback())
def test_dinner_only_on_off_peak_days(self):
self.assertTrue(create_premium_booking(HAMLET, "周三", EXTRAS).has_dinner())
self.assertFalse(create_premium_booking(HAMLET, "周五", EXTRAS).has_dinner())
def test_describe(self):
self.assertEqual(describe(create_booking(HAMLET, "周六")), "哈姆雷特 周六 票价 230 演后谈无")
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { createBooking, createPremiumBooking, describe, type Extras, type Show } from "./target";
const HAMLET: Show = { name: "哈姆雷特", price: 200, talkback: true };
const EXTRAS: Extras = { premiumFee: 80, dinner: true };
test("普通订票", () => {
assert.equal(createBooking(HAMLET, "周三").basePrice(), 200);
assert.equal(createBooking(HAMLET, "周六").hasTalkback(), false);
});
test("尊享订票", () => {
assert.equal(createPremiumBooking(HAMLET, "周六", EXTRAS).basePrice(), 310);
assert.equal(createPremiumBooking(HAMLET, "周六", EXTRAS).hasTalkback(), true);
});
test("只有非高峰日送晚餐", () => {
assert.equal(createPremiumBooking(HAMLET, "周三", EXTRAS).hasDinner(), true);
assert.equal(createPremiumBooking(HAMLET, "周五", EXTRAS).hasDinner(), false);
});
test("描述", () => {
assert.equal(describe(createBooking(HAMLET, "周六")), "哈姆雷特 周六 票价 230 演后谈无");
});
"""以委托取代超类:重构前。"""
from datetime import date
class CatalogItem:
def __init__(self, id_: str, title: str, tags: list[str]) -> None:
self.id = id_
self.title = title
self.tags = tags
def has_tag(self, tag: str) -> bool:
return tag in self.tags
class Scroll(CatalogItem):
def __init__(self, id_: str, title: str, tags: list[str], last_cleaned: date) -> None:
super().__init__(id_, title, tags)
self.last_cleaned = last_cleaned
def needs_cleaning(self, today: date) -> bool:
threshold = 700 if self.has_tag("revered") else 1500
return (today - self.last_cleaned).days > threshold
def load_scrolls(records: list[dict], catalog: dict[str, CatalogItem]) -> list[Scroll]:
scrolls = []
for r in records:
item = catalog[r["catalog_id"]]
cleaned = date.fromisoformat(r["last_cleaned"])
scrolls.append(Scroll(r["id"], item.title, list(item.tags), cleaned))
return scrolls
CATALOG = {
"C1": CatalogItem("C1", "道德经", ["revered", "philosophy"]),
"C2": CatalogItem("C2", "九章算术", ["math"]),
}
RECORDS = [
{"id": "S1", "catalog_id": "C1", "last_cleaned": "2024-01-10"},
{"id": "S2", "catalog_id": "C1", "last_cleaned": "2025-06-01"},
{"id": "S3", "catalog_id": "C2", "last_cleaned": "2022-01-01"},
]
if __name__ == "__main__":
for s in load_scrolls(RECORDS, CATALOG):
print(s.id, s.title, "需要清理" if s.needs_cleaning(date(2026, 10, 8)) else "不用清理")
# 输出:
# S1 道德经 需要清理
# S2 道德经 不用清理
# S3 九章算术 需要清理
// 以委托取代超类:重构前。
const DAY_MS = 24 * 60 * 60 * 1000;
export class CatalogItem {
constructor(
readonly id: string,
readonly title: string,
readonly tags: string[],
) {}
hasTag(tag: string): boolean {
return this.tags.includes(tag);
}
}
export class Scroll extends CatalogItem {
constructor(
id: string,
title: string,
tags: string[],
readonly lastCleaned: Date,
) {
super(id, title, tags);
}
needsCleaning(today: Date): boolean {
const threshold = this.hasTag("revered") ? 700 : 1500;
return Math.floor((today.getTime() - this.lastCleaned.getTime()) / DAY_MS) > threshold;
}
}
export interface ScrollRecord {
id: string;
catalogId: string;
lastCleaned: string;
}
export function loadScrolls(records: ScrollRecord[], catalog: Map<string, CatalogItem>): Scroll[] {
return records.map((r) => {
const item = catalog.get(r.catalogId)!;
return new Scroll(r.id, item.title, [...item.tags], new Date(r.lastCleaned));
});
}
const catalog = new Map([
["C1", new CatalogItem("C1", "道德经", ["revered", "philosophy"])],
["C2", new CatalogItem("C2", "九章算术", ["math"])],
]);
const records: ScrollRecord[] = [
{ id: "S1", catalogId: "C1", lastCleaned: "2024-01-10" },
{ id: "S2", catalogId: "C1", lastCleaned: "2025-06-01" },
{ id: "S3", catalogId: "C2", lastCleaned: "2022-01-01" },
];
for (const s of loadScrolls(records, catalog)) {
console.log(s.id, s.title, s.needsCleaning(new Date("2026-10-08")) ? "需要清理" : "不用清理");
}
// 输出:
// S1 道德经 需要清理
// S2 道德经 不用清理
// S3 九章算术 需要清理
import unittest
from datetime import date
from target import CatalogItem, load_scrolls
CATALOG = {"C1": CatalogItem("C1", "道德经", ["revered"]), "C2": CatalogItem("C2", "九章算术", [])}
TODAY = date(2026, 10, 8)
def scroll(catalog_id: str, last_cleaned: str):
return load_scrolls([{"id": "S9", "catalog_id": catalog_id, "last_cleaned": last_cleaned}], CATALOG)[0]
class ScrollTest(unittest.TestCase):
def test_title_and_tags_come_from_catalog(self):
s = scroll("C1", "2026-01-01")
self.assertEqual((s.id, s.title, s.has_tag("revered")), ("S9", "道德经", True))
def test_revered_scrolls_need_cleaning_after_700_days(self):
self.assertTrue(scroll("C1", "2024-09-01").needs_cleaning(TODAY))
self.assertFalse(scroll("C1", "2025-01-01").needs_cleaning(TODAY))
def test_others_wait_1500_days(self):
self.assertFalse(scroll("C2", "2024-09-01").needs_cleaning(TODAY))
self.assertTrue(scroll("C2", "2022-01-01").needs_cleaning(TODAY))
if __name__ == "__main__":
unittest.main()
import test from "node:test";
import assert from "node:assert/strict";
import { CatalogItem, loadScrolls } from "./target";
const CATALOG = new Map([
["C1", new CatalogItem("C1", "道德经", ["revered"])],
["C2", new CatalogItem("C2", "九章算术", [])],
]);
const TODAY = new Date("2026-10-08");
function scroll(catalogId: string, lastCleaned: string) {
return loadScrolls([{ id: "S9", catalogId, lastCleaned }], CATALOG)[0];
}
test("书名和标签来自目录", () => {
const s = scroll("C1", "2026-01-01");
assert.deepEqual([s.id, s.title, s.hasTag("revered")], ["S9", "道德经", true]);
});
test("珍贵古卷 700 天要清理", () => {
assert.equal(scroll("C1", "2024-09-01").needsCleaning(TODAY), true);
assert.equal(scroll("C1", "2025-01-01").needsCleaning(TODAY), false);
});
test("其他古卷 1500 天", () => {
assert.equal(scroll("C2", "2024-09-01").needsCleaning(TODAY), false);
assert.equal(scroll("C2", "2022-01-01").needsCleaning(TODAY), true);
});

先看订票。如果现在要做“补差价升级成尊享”,小郑会发现做不到:一个 Booking 对象创建出来就是 Booking,Python 和 TS 都不该在运行时把它变成 PremiumBooking。他只能新建一个 PremiumBooking,把旧对象的所有字段复制过去,再把系统里所有指向旧对象的引用(订单、座位表、通知队列)一个个换成新对象,漏换一处,那一处看到的就还是普通票。

接着运营还想按渠道区分:网上订票和窗口订票的退票规则不同。继承只能沿一个方向分类,要同时按“普通 / 尊享”和“网上 / 窗口”区分,就得建出“网上尊享”“窗口尊享”“网上普通”“窗口普通”四个子类,再加一个维度就是八个。

再看古卷。Scroll 继承了 CatalogItem,于是“一卷实物”和“目录里的一条书目”被当成了同一种东西。《道德经》在馆里有两卷,就有两个对象各自存着一份书名和标签的副本;馆员在目录里把书名改成《老子道德经》,两卷古卷上还是旧名字。id 也被混用了:古卷的编号 S1 存进了 CatalogItem 的 id 字段,读代码的人要想一想这个 id 是哪一种编号。将来 CatalogItem 再加 rename、add_tag 这样维护目录的方法,Scroll 也会全部继承下来,可是在一卷实物上调用“改书名”是没有意义的。

用第 2 章的名字说,订票那边,尊享和普通的差别本该是一种可以换的配置,却被做成了改不了的继承,最后会长成组合出来的一堆子类,即过大的类的另一种形状:膨胀的继承体系;古卷这边是典型的被拒绝的遗赠:子类继承了父类的全部接口,真正需要的只有书名和标签两样。

以委托取代子类之前:PremiumBooking 继承 Booking,覆盖 has_talkback 和 base_price,新增 has_dinner

以委托取代超类之前:Scroll 继承 CatalogItem,把 id、title、tags 和 has_tag 全部接了过来

"""以委托取代子类:重构后。"""
from dataclasses import dataclass
PEAK_DAYS = {"周五", "周六"}
@dataclass
class Show:
name: str
price: int
talkback: bool
@dataclass
class Extras:
premium_fee: int
dinner: bool
class Booking:
def __init__(self, show: Show, day: str) -> None:
self.show = show
self.day = day
self._premium: "PremiumBookingDelegate | None" = None
def be_premium(self, extras: Extras) -> None:
self._premium = PremiumBookingDelegate(self, extras)
def is_peak_day(self) -> bool:
return self.day in PEAK_DAYS
def has_talkback(self) -> bool:
if self._premium:
return self._premium.has_talkback()
return self.show.talkback and not self.is_peak_day()
def base_price(self) -> int:
result = self.show.price
if self.is_peak_day():
result += result * 15 // 100
if self._premium:
result = self._premium.extend_base_price(result)
return result
def has_dinner(self) -> bool:
return self._premium.has_dinner() if self._premium else False
class PremiumBookingDelegate:
def __init__(self, host: Booking, extras: Extras) -> None:
self.host = host
self.extras = extras
def has_talkback(self) -> bool:
return self.host.show.talkback
def extend_base_price(self, base: int) -> int:
return base + self.extras.premium_fee
def has_dinner(self) -> bool:
return self.extras.dinner and not self.host.is_peak_day()
def create_booking(show: Show, day: str) -> Booking:
return Booking(show, day)
def create_premium_booking(show: Show, day: str, extras: Extras) -> Booking:
booking = Booking(show, day)
booking.be_premium(extras)
return booking
def describe(b: Booking) -> str:
return f"{b.show.name} {b.day} 票价 {b.base_price()} 演后谈{'有' if b.has_talkback() else '无'}"
if __name__ == "__main__":
hamlet = Show("哈姆雷特", 200, True)
extras = Extras(premium_fee=80, dinner=True)
print(describe(create_booking(hamlet, "周三")))
print(describe(create_booking(hamlet, "周六")))
print(describe(create_premium_booking(hamlet, "周三", extras)))
print(describe(create_premium_booking(hamlet, "周六", extras)))
print(create_premium_booking(hamlet, "周三", extras).has_dinner())
# 输出:
# 哈姆雷特 周三 票价 200 演后谈有
# 哈姆雷特 周六 票价 230 演后谈无
# 哈姆雷特 周三 票价 280 演后谈有
# 哈姆雷特 周六 票价 310 演后谈有
# True
// 以委托取代子类:重构后。
const PEAK_DAYS = new Set(["周五", "周六"]);
export interface Show {
name: string;
price: number;
talkback: boolean;
}
export interface Extras {
premiumFee: number;
dinner: boolean;
}
export class Booking {
private premium?: PremiumBookingDelegate;
constructor(
readonly show: Show,
readonly day: string,
) {}
bePremium(extras: Extras): void {
this.premium = new PremiumBookingDelegate(this, extras);
}
isPeakDay(): boolean {
return PEAK_DAYS.has(this.day);
}
hasTalkback(): boolean {
if (this.premium) return this.premium.hasTalkback();
return this.show.talkback && !this.isPeakDay();
}
basePrice(): number {
let result = this.show.price;
if (this.isPeakDay()) result += Math.floor((result * 15) / 100);
if (this.premium) result = this.premium.extendBasePrice(result);
return result;
}
hasDinner(): boolean {
return this.premium ? this.premium.hasDinner() : false;
}
}
class PremiumBookingDelegate {
constructor(
private readonly host: Booking,
private readonly extras: Extras,
) {}
hasTalkback(): boolean {
return this.host.show.talkback;
}
extendBasePrice(base: number): number {
return base + this.extras.premiumFee;
}
hasDinner(): boolean {
return this.extras.dinner && !this.host.isPeakDay();
}
}
export function createBooking(show: Show, day: string): Booking {
return new Booking(show, day);
}
export function createPremiumBooking(show: Show, day: string, extras: Extras): Booking {
const booking = new Booking(show, day);
booking.bePremium(extras);
return booking;
}
export function describe(b: Booking): string {
return `${b.show.name} ${b.day} 票价 ${b.basePrice()} 演后谈${b.hasTalkback() ? "有" : "无"}`;
}
const hamlet: Show = { name: "哈姆雷特", price: 200, talkback: true };
const extras: Extras = { premiumFee: 80, dinner: true };
console.log(describe(createBooking(hamlet, "周三")));
console.log(describe(createBooking(hamlet, "周六")));
console.log(describe(createPremiumBooking(hamlet, "周三", extras)));
console.log(describe(createPremiumBooking(hamlet, "周六", extras)));
console.log(createPremiumBooking(hamlet, "周三", extras).hasDinner());
// 输出:
// 哈姆雷特 周三 票价 200 演后谈有
// 哈姆雷特 周六 票价 230 演后谈无
// 哈姆雷特 周三 票价 280 演后谈有
// 哈姆雷特 周六 票价 310 演后谈有
// true

以委托取代子类之后:Booking 持有一个可选的 PremiumBookingDelegate,委托对象通过 host 回头读 Booking 的数据

升级成尊享现在是一行代码,对象还是原来那个对象,订单、座位表里的引用都不用换:

booking = create_booking(hamlet, "周三")
booking.be_premium(extras) # 补差价升级
print(booking.base_price()) # 280

按渠道区分也不再需要四个子类:再给 Booking 加一个“渠道委托”,网上、窗口各一个实现,和尊享委托互不相干,两个维度各自变化。

注意 base_price 的写法:父类先算出普通票价,再交给委托对象 extend_base_price 在此基础上加钱。这对应原来子类里的 super().base_price() + ...。委托对象没有 super 可调,所以由宿主把“父类那一份”算好递给它。has_dinner 也搬到了 Booking 上,普通订票返回 False;原来普通订票上根本没有这个方法,调用会抛异常,测试只对尊享订票调用它,所以两版都通过。

"""以委托取代超类:重构后。"""
from datetime import date
class CatalogItem:
def __init__(self, id_: str, title: str, tags: list[str]) -> None:
self.id = id_
self.title = title
self.tags = tags
def has_tag(self, tag: str) -> bool:
return tag in self.tags
class Scroll:
def __init__(self, id_: str, catalog_item: CatalogItem, last_cleaned: date) -> None:
self.id = id_
self.catalog_item = catalog_item
self.last_cleaned = last_cleaned
@property
def title(self) -> str:
return self.catalog_item.title
def has_tag(self, tag: str) -> bool:
return self.catalog_item.has_tag(tag)
def needs_cleaning(self, today: date) -> bool:
threshold = 700 if self.has_tag("revered") else 1500
return (today - self.last_cleaned).days > threshold
def load_scrolls(records: list[dict], catalog: dict[str, CatalogItem]) -> list[Scroll]:
return [
Scroll(r["id"], catalog[r["catalog_id"]], date.fromisoformat(r["last_cleaned"]))
for r in records
]
CATALOG = {
"C1": CatalogItem("C1", "道德经", ["revered", "philosophy"]),
"C2": CatalogItem("C2", "九章算术", ["math"]),
}
RECORDS = [
{"id": "S1", "catalog_id": "C1", "last_cleaned": "2024-01-10"},
{"id": "S2", "catalog_id": "C1", "last_cleaned": "2025-06-01"},
{"id": "S3", "catalog_id": "C2", "last_cleaned": "2022-01-01"},
]
if __name__ == "__main__":
for s in load_scrolls(RECORDS, CATALOG):
print(s.id, s.title, "需要清理" if s.needs_cleaning(date(2026, 10, 8)) else "不用清理")
# 输出:
# S1 道德经 需要清理
# S2 道德经 不用清理
# S3 九章算术 需要清理
// 以委托取代超类:重构后。
const DAY_MS = 24 * 60 * 60 * 1000;
export class CatalogItem {
constructor(
readonly id: string,
readonly title: string,
readonly tags: string[],
) {}
hasTag(tag: string): boolean {
return this.tags.includes(tag);
}
}
export class Scroll {
constructor(
readonly id: string,
readonly catalogItem: CatalogItem,
readonly lastCleaned: Date,
) {}
get title(): string {
return this.catalogItem.title;
}
hasTag(tag: string): boolean {
return this.catalogItem.hasTag(tag);
}
needsCleaning(today: Date): boolean {
const threshold = this.hasTag("revered") ? 700 : 1500;
return Math.floor((today.getTime() - this.lastCleaned.getTime()) / DAY_MS) > threshold;
}
}
export interface ScrollRecord {
id: string;
catalogId: string;
lastCleaned: string;
}
export function loadScrolls(records: ScrollRecord[], catalog: Map<string, CatalogItem>): Scroll[] {
return records.map((r) => new Scroll(r.id, catalog.get(r.catalogId)!, new Date(r.lastCleaned)));
}
const catalog = new Map([
["C1", new CatalogItem("C1", "道德经", ["revered", "philosophy"])],
["C2", new CatalogItem("C2", "九章算术", ["math"])],
]);
const records: ScrollRecord[] = [
{ id: "S1", catalogId: "C1", lastCleaned: "2024-01-10" },
{ id: "S2", catalogId: "C1", lastCleaned: "2025-06-01" },
{ id: "S3", catalogId: "C2", lastCleaned: "2022-01-01" },
];
for (const s of loadScrolls(records, catalog)) {
console.log(s.id, s.title, s.needsCleaning(new Date("2026-10-08")) ? "需要清理" : "不用清理");
}
// 输出:
// S1 道德经 需要清理
// S2 道德经 不用清理
// S3 九章算术 需要清理

以委托取代超类之后:Scroll 不再继承 CatalogItem,而是持有它的引用,title 和 has_tag 转发过去

两卷《道德经》现在指向同一个 CatalogItem 对象。馆员改了书名,所有古卷上看到的都是新名字,因为书名只存在一处。Scroll 的 id 就是古卷编号,目录编号在 catalog_item.id 里,两种编号不再混在一个字段上。Scroll 的接口只有 id、title、has_tag、needs_cleaning 这几样,CatalogItem 以后加多少维护目录的方法,都不会出现在古卷身上。

以委托取代子类,每一步之后跑测试:

  1. 如果子类的构造函数被很多地方直接调用,先用以工厂函数取代构造函数(第 48 章)收拢到 create_premium_booking。
  2. 建一个空的委托类 PremiumBookingDelegate,构造参数是子类特有的数据 extras,再加一个指回宿主的引用 host,因为子类的方法常常要读父类的数据。
  3. 在父类上加一个字段 _premium,默认为空。再加一个 be_premium(extras) 方法创建委托对象。工厂函数里创建子类之后调用它。
  4. 挑一个子类方法,比如 has_talkback,用搬移函数(第 21 章)搬进委托类,把对 self.show 的访问改成 self.host.show。子类方法改成调用委托。
  5. 把这个方法的分派逻辑放进父类:父类的 has_talkback 先看有没有委托,有就交给委托,没有就走原来的逻辑。删掉子类里的版本。
  6. 调用了 super() 的子类方法,比如 base_price,要换个形状:父类算完自己的部分,把结果交给委托的 extend_base_price 去扩展。
  7. 子类独有的方法 has_dinner 搬进委托,父类上加一个转发方法,没有委托时返回合适的默认值。
  8. 子类空了之后,工厂函数改成创建父类实例再调用 be_premium,删掉子类。

以委托取代超类:

  1. 在子类里加一个字段 catalog_item,指向一个新建的父类实例,先用子类自己收到的数据构造它。
  2. 对子类用到的每个父类成员(title、has_tag),在子类里写一个转发方法,交给 catalog_item。每加一个跑一次测试。
  3. 所有用到的父类成员都有了转发之后,去掉继承关系。跑测试,类型检查会指出漏掉的成员。
  4. 这时每个古卷还各自持有一个新建的目录条目副本。改 load_scrolls,让古卷直接引用目录里那个共享的 CatalogItem,这一步就是将值对象改为引用对象(第 33 章)。

IntelliJ IDEA 对 Java 有一键的 Replace Inheritance with Delegation;Python 和 TS 的 IDE 目前没有对应命令,第 2 步的转发方法要手写,好在每个都只有一行,去掉继承之后类型检查会指出漏写的那些。第 4 步哪些数据该共享,只能自己判断。

以委托取代子类:把子类的特殊行为搬进一个委托对象,父类按需持有它,从而去掉子类。以委托取代超类:去掉继承关系,子类改为持有原父类的一个实例,只转发需要的成员。

以委托取代子类适合两种情况。一是类型要在对象生命期内改变,本章的升级尊享就是;二是要沿多个维度变化,每个维度一个委托,避免组合出一堆子类。代价是父类里多了“有没有委托”的分派代码,读代码时也要多跳一层。如果类型永远不变、只有一个维度,子类更直接,第 52 章的做法就够了。

以委托取代超类的判断标准来自里氏替换原则:凡是用到父类的地方,换成子类都应该说得通。“一卷古卷是一个目录条目吗?“在系统里答案是否定的,目录条目可以改书名、加标签,古卷不该做这些事。父类的接口里只要有一部分对子类没有意义,继承就在说谎,改成持有更诚实。另一个信号是数据重复:同一份父类数据在多个子类对象里各存一份,说明它们本该共享同一个对象。

两个手法都没有直接的反向手法,但“该不该用继承”可以两边走:委托用起来发现到处都在转发、委托对象和宿主一样大,说明它们本来就是“是一种”的关系,用子类更简单。

两个手法都在实践“组合优于继承”(favor composition over inheritance),这句话出自《设计模式》一书的引言:继承在编译时就定下了,暴露了父类的内部;组合在运行时可以换,只依赖对方的接口。

以委托取代子类的结果,常常就是策略(Strategy)模式:Booking 是环境,委托是策略,不同的委托实现不同的计价和权益规则。本章只有一种委托,再加一个“会员委托”、“团体委托”,让它们实现同一组方法,就是完整的策略模式,见《设计模式》书第 17 章。当委托会在对象生命期内切换时(升级尊享),它也带着状态模式的影子。

要沿两个维度独立变化时,每个维度一组委托,就接近桥接(Bridge)模式:抽象的一侧(订票)和实现的一侧(渠道规则)各自扩展,用持有关系连起来,避免子类数量相乘。桥接模式见《设计模式》书第 14 章。

Django 的文件存储是委托的教科书例子:模型字段不继承某个存储类,而是持有一个 storage 对象,本地磁盘、云存储、测试用的内存存储都实现同一组方法,在配置里换一行就切换。下面先看继承版:AvatarUploaderV1 继承 LocalStorage,于是测试时也只能写本地文件;委托版的 AvatarUploader 持有一个存储后端,可以随时换成内存存储。

"""可插拔存储:用持有一个存储后端取代继承存储类。"""
from typing import Protocol
class LocalStorage:
"""模拟本地磁盘存储:用字典代替文件系统。"""
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
def save(self, name: str, data: bytes) -> str:
self.files[name] = data
return f"/media/{name}"
def delete_all(self) -> None:
self.files.clear()
class MemoryStorage:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
def save(self, name: str, data: bytes) -> str:
self.files[name] = data
return f"memory://{name}"
# 继承版:上传器"是一个"本地存储,连 delete_all 也一并继承了。
class AvatarUploaderV1(LocalStorage):
def upload(self, user_id: int, data: bytes) -> str:
return self.save(f"avatars/{user_id}.png", data)
# 委托版:上传器"有一个"存储,只用到 save。
class Storage(Protocol):
def save(self, name: str, data: bytes) -> str: ...
class AvatarUploader:
def __init__(self, storage: Storage) -> None:
self.storage = storage
def upload(self, user_id: int, data: bytes) -> str:
return self.storage.save(f"avatars/{user_id}.png", data)
if __name__ == "__main__":
print(AvatarUploaderV1().upload(7, b"png") == AvatarUploader(LocalStorage()).upload(7, b"png"))
print(AvatarUploader(MemoryStorage()).upload(7, b"png"))
print(hasattr(AvatarUploaderV1(), "delete_all"), hasattr(AvatarUploader(MemoryStorage()), "delete_all"))
# 输出:
# True
# memory://avatars/7.png
# True False

最后一行说明了被拒绝的遗赠:继承版的上传器身上有一个 delete_all,谁误调了它,所有上传的文件都没了;委托版只暴露 upload。Storage 用 Protocol 声明,任何有 save 方法的对象都能当后端,不需要继承同一个基类。

React 的旧版官方文档有一节专门讲“组合与继承”,结论是组件之间几乎不需要继承。下面用返回字符串的函数和类模拟组件:继承版的 WelcomeDialog 继承 Dialog 并覆盖 body;组合版的 Dialog 接收 children,WelcomeDialog 只是用特定内容调用它。

// 模拟 React:用组合代替继承。
// 继承版:子类覆盖 body。
class DialogV1 {
title(): string {
return "提示";
}
body(): string {
return "";
}
render(): string {
return `<div class="dialog"><h1>${this.title()}</h1>${this.body()}</div>`;
}
}
class WelcomeDialogV1 extends DialogV1 {
title(): string {
return "欢迎";
}
body(): string {
return "<p>感谢光临</p>";
}
}
// 组合版:内容作为参数传进去,就像 React 的 props.children。
interface DialogProps {
title: string;
children: string;
}
function Dialog({ title, children }: DialogProps): string {
return `<div class="dialog"><h1>${title}</h1>${children}</div>`;
}
function WelcomeDialog(): string {
return Dialog({ title: "欢迎", children: "<p>感谢光临</p>" });
}
console.log(new WelcomeDialogV1().render() === WelcomeDialog());
console.log(WelcomeDialog());
console.log(Dialog({ title: "警告", children: "<p>余额不足</p>" }));
// 输出:
// true
// <div class="dialog"><h1>欢迎</h1><p>感谢光临</p></div>
// <div class="dialog"><h1>警告</h1><p>余额不足</p></div>

组合版里,要一个新对话框不需要新建子类,传不同的参数就行;Dialog 也不用为了被继承而设计一堆可覆盖的方法。这是以委托取代超类的组件版本:WelcomeDialog 不再“是一个” Dialog,而是“用了一个” Dialog。

继承用得好的地方别换。如果子类真的“是一种”父类,替换到任何地方都说得通,类型也不会在运行时变,只有一个变化维度,那么继承是最省事的写法:不用写转发方法,不用在父类里判断有没有委托。为了“组合优于继承”这句口号把所有继承都换成委托,得到的是满屏一行的转发方法。

以委托取代超类时,如果子类用到了父类的大部分接口,转发方法会多得吓人,每次父类加一个方法,子类都要跟着加一个转发。这说明子类其实就是父类的一种,继承没有错,错的可能只是父类上有一两个不该有的方法,用函数下移(第 51 章)处理它们就够了。

以委托取代子类会让父类的方法里多出“有委托就交给委托”的分支。委托只有一种、分支只有一两处时,这点代价可以接受;如果每个方法都要判断,还要处理“委托为空”的默认值,就该考虑反过来:让普通订票也有一个“普通委托”,父类总是交给委托,这样分支就没了,也就是第 39 章引入特例的做法。

手法 它关心什么 以委托取代子类 / 超类关心什么
以子类取代类型码(第 52 章) 用继承表达类型差异 继承表达不了运行时变化和多维度,改用持有
提炼类(第 17 章) 从一个大类里拆出一组字段和方法 拆的依据是继承关系:子类的特殊行为,或者父类的那部分接口
隐藏委托关系(第 18 章) 在宿主上加转发方法,让调用方不必穿透到委托对象 以委托取代超类的第 2 步就是在写这种转发方法

下面的 Stack 继承了列表,于是它也有 insert、sort、remove 这些栈不该有的方法,谁调用了 insert(0, x) 栈就乱了。用以委托取代超类把它改成持有一个列表,只暴露 push、pop、peek 和长度。

"""练习:栈。"""
class Stack(list[int]):
def push(self, item: int) -> None:
self.append(item)
def peek(self) -> int:
return self[-1]
if __name__ == "__main__":
s = Stack()
s.push(1)
s.push(2)
print(s.peek(), s.pop(), len(s))
# 输出:
# 2 2 1
import unittest
from target import Stack
class StackTest(unittest.TestCase):
def test_push_and_peek(self):
s = Stack()
s.push(1)
s.push(2)
self.assertEqual(s.peek(), 2)
self.assertEqual(len(s), 2)
def test_pop_returns_last_item(self):
s = Stack()
s.push(1)
s.push(2)
self.assertEqual(s.pop(), 2)
self.assertEqual(len(s), 1)
if __name__ == "__main__":
unittest.main()
// 练习:栈。
export class Stack extends Array<number> {
peek(): number | undefined {
return this[this.length - 1];
}
}
const s = new Stack();
s.push(1);
s.push(2);
console.log(s.peek(), s.pop(), s.length);
// 输出:
// 2 2 1
import test from "node:test";
import assert from "node:assert/strict";
import { Stack } from "./target";
test("压栈和查看栈顶", () => {
const s = new Stack();
s.push(1);
s.push(2);
assert.equal(s.peek(), 2);
assert.equal(s.length, 2);
});
test("出栈返回最后一个元素", () => {
const s = new Stack();
s.push(1);
s.push(2);
assert.equal(s.pop(), 2);
assert.equal(s.length, 1);
});
  • 继承一旦定下就改不了,也只能沿一个方向分类;类型要在运行时变、或要按多个维度变化时,用以委托取代子类。
  • 委托对象通过指回宿主的引用读宿主的数据;子类里调用 super() 的方法,改成宿主算好基础部分交给委托扩展。
  • 子类只用到父类接口的一部分、或同一份父类数据被多个子类对象各存一份时,用以委托取代超类,只转发需要的成员。
  • 结果常常是策略模式,多维度时接近桥接模式;口号是组合优于继承,但真正“是一种”的关系继续用继承。