以委托取代子类 / 以委托取代超类
以委托取代子类(Replace Subclass with Delegate)把一个子类的特殊行为搬进一个单独的委托对象,原来的父类持有它;以委托取代超类(Replace Superclass with Delegate)把“继承一个父类”改成“持有一个它的实例”,只转发真正用得上的那几个方法。 它们解决的痛是:继承定下来就改不了,对象创建出来是什么子类就永远是什么子类,也只能沿一个方向分类;而继承一个父类会把它的全部成员连同以后加的成员一起接过来,用不上的也甩不掉。
小郑在剧院的售票系统里维护两块代码。第一块是订票:普通订票 Booking,还有一个子类 PremiumBooking 表示尊享订票,票价多一笔尊享费,演后谈总能参加,非高峰日还送晚餐。运营提出新需求:观众买了普通票之后,可以在演出前补差价升级成尊享。第二块是藏书馆的古卷管理:Scroll(一卷实物古卷)继承自 CatalogItem(目录里的一个条目),馆员发现改了目录里的书名,已经加载的古卷对象上还是旧名字。
订票:尊享是一个子类
Section titled “订票:尊享是一个子类”"""以委托取代子类:重构前。"""from dataclasses import dataclass
PEAK_DAYS = {"周五", "周六"}
@dataclassclass Show: name: str price: int talkback: bool
@dataclassclass 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 演后谈无");});古卷:继承了目录条目
Section titled “古卷:继承了目录条目”"""以委托取代超类:重构前。"""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 unittestfrom 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 章的名字说,订票那边,尊享和普通的差别本该是一种可以换的配置,却被做成了改不了的继承,最后会长成组合出来的一堆子类,即过大的类的另一种形状:膨胀的继承体系;古卷这边是典型的被拒绝的遗赠:子类继承了父类的全部接口,真正需要的只有书名和标签两样。
订票:尊享变成委托对象
Section titled “订票:尊享变成委托对象”"""以委托取代子类:重构后。"""from dataclasses import dataclass
PEAK_DAYS = {"周五", "周六"}
@dataclassclass Show: name: str price: int talkback: bool
@dataclassclass 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 = 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;原来普通订票上根本没有这个方法,调用会抛异常,测试只对尊享订票调用它,所以两版都通过。
古卷:持有目录条目
Section titled “古卷:持有目录条目”"""以委托取代超类:重构后。"""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 九章算术 需要清理两卷《道德经》现在指向同一个 CatalogItem 对象。馆员改了书名,所有古卷上看到的都是新名字,因为书名只存在一处。Scroll 的 id 就是古卷编号,目录编号在 catalog_item.id 里,两种编号不再混在一个字段上。Scroll 的接口只有 id、title、has_tag、needs_cleaning 这几样,CatalogItem 以后加多少维护目录的方法,都不会出现在古卷身上。
怎么一步步做
Section titled “怎么一步步做”以委托取代子类,每一步之后跑测试:
- 如果子类的构造函数被很多地方直接调用,先用以工厂函数取代构造函数(第 48 章)收拢到
create_premium_booking。 - 建一个空的委托类
PremiumBookingDelegate,构造参数是子类特有的数据extras,再加一个指回宿主的引用host,因为子类的方法常常要读父类的数据。 - 在父类上加一个字段
_premium,默认为空。再加一个be_premium(extras)方法创建委托对象。工厂函数里创建子类之后调用它。 - 挑一个子类方法,比如
has_talkback,用搬移函数(第 21 章)搬进委托类,把对self.show的访问改成self.host.show。子类方法改成调用委托。 - 把这个方法的分派逻辑放进父类:父类的
has_talkback先看有没有委托,有就交给委托,没有就走原来的逻辑。删掉子类里的版本。 - 调用了
super()的子类方法,比如base_price,要换个形状:父类算完自己的部分,把结果交给委托的extend_base_price去扩展。 - 子类独有的方法
has_dinner搬进委托,父类上加一个转发方法,没有委托时返回合适的默认值。 - 子类空了之后,工厂函数改成创建父类实例再调用
be_premium,删掉子类。
以委托取代超类:
- 在子类里加一个字段
catalog_item,指向一个新建的父类实例,先用子类自己收到的数据构造它。 - 对子类用到的每个父类成员(
title、has_tag),在子类里写一个转发方法,交给catalog_item。每加一个跑一次测试。 - 所有用到的父类成员都有了转发之后,去掉继承关系。跑测试,类型检查会指出漏掉的成员。
- 这时每个古卷还各自持有一个新建的目录条目副本。改
load_scrolls,让古卷直接引用目录里那个共享的CatalogItem,这一步就是将值对象改为引用对象(第 33 章)。
IntelliJ IDEA 对 Java 有一键的 Replace Inheritance with Delegation;Python 和 TS 的 IDE 目前没有对应命令,第 2 步的转发方法要手写,好在每个都只有一行,去掉继承之后类型检查会指出漏写的那些。第 4 步哪些数据该共享,只能自己判断。
定义与适用场景
Section titled “定义与适用场景”以委托取代子类:把子类的特殊行为搬进一个委托对象,父类按需持有它,从而去掉子类。以委托取代超类:去掉继承关系,子类改为持有原父类的一个实例,只转发需要的成员。
以委托取代子类适合两种情况。一是类型要在对象生命期内改变,本章的升级尊享就是;二是要沿多个维度变化,每个维度一个委托,避免组合出一堆子类。代价是父类里多了“有没有委托”的分派代码,读代码时也要多跳一层。如果类型永远不变、只有一个维度,子类更直接,第 52 章的做法就够了。
以委托取代超类的判断标准来自里氏替换原则:凡是用到父类的地方,换成子类都应该说得通。“一卷古卷是一个目录条目吗?“在系统里答案是否定的,目录条目可以改书名、加标签,古卷不该做这些事。父类的接口里只要有一部分对子类没有意义,继承就在说谎,改成持有更诚实。另一个信号是数据重复:同一份父类数据在多个子类对象里各存一份,说明它们本该共享同一个对象。
两个手法都没有直接的反向手法,但“该不该用继承”可以两边走:委托用起来发现到处都在转发、委托对象和宿主一样大,说明它们本来就是“是一种”的关系,用子类更简单。
相关的设计模式
Section titled “相关的设计模式”两个手法都在实践“组合优于继承”(favor composition over inheritance),这句话出自《设计模式》一书的引言:继承在编译时就定下了,暴露了父类的内部;组合在运行时可以换,只依赖对方的接口。
以委托取代子类的结果,常常就是策略(Strategy)模式:Booking 是环境,委托是策略,不同的委托实现不同的计价和权益规则。本章只有一种委托,再加一个“会员委托”、“团体委托”,让它们实现同一组方法,就是完整的策略模式,见《设计模式》书第 17 章。当委托会在对象生命期内切换时(升级尊享),它也带着状态模式的影子。
要沿两个维度独立变化时,每个维度一组委托,就接近桥接(Bridge)模式:抽象的一侧(订票)和实现的一侧(渠道规则)各自扩展,用持有关系连起来,避免子类数量相乘。桥接模式见《设计模式》书第 14 章。
真实系统里的例子
Section titled “真实系统里的例子”Python 的可插拔存储后端
Section titled “Python 的可插拔存储后端”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 用组合代替继承
Section titled “React 用组合代替继承”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。
什么时候别用
Section titled “什么时候别用”继承用得好的地方别换。如果子类真的“是一种”父类,替换到任何地方都说得通,类型也不会在运行时变,只有一个变化维度,那么继承是最省事的写法:不用写转发方法,不用在父类里判断有没有委托。为了“组合优于继承”这句口号把所有继承都换成委托,得到的是满屏一行的转发方法。
以委托取代超类时,如果子类用到了父类的大部分接口,转发方法会多得吓人,每次父类加一个方法,子类都要跟着加一个转发。这说明子类其实就是父类的一种,继承没有错,错的可能只是父类上有一两个不该有的方法,用函数下移(第 51 章)处理它们就够了。
以委托取代子类会让父类的方法里多出“有委托就交给委托”的分支。委托只有一种、分支只有一两处时,这点代价可以接受;如果每个方法都要判断,还要处理“委托为空”的默认值,就该考虑反过来:让普通订票也有一个“普通委托”,父类总是交给委托,这样分支就没了,也就是第 39 章引入特例的做法。
容易混淆的手法
Section titled “容易混淆的手法”| 手法 | 它关心什么 | 以委托取代子类 / 超类关心什么 |
|---|---|---|
| 以子类取代类型码(第 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 1import 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 1import 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()的方法,改成宿主算好基础部分交给委托扩展。 - 子类只用到父类接口的一部分、或同一份父类数据被多个子类对象各存一份时,用以委托取代超类,只转发需要的成员。
- 结果常常是策略模式,多维度时接近桥接模式;口号是组合优于继承,但真正“是一种”的关系继续用继承。