第 13 章 · 组合(Composite)
一句话:让单个对象和装着一组对象的容器实现同一个接口,容器把调用转给每个孩子再汇总,调用方不用分清手里是哪一种。 解决的痛:单品和套餐接口不同,每个用到购物车的函数都要写
isinstance分支;套餐能套套餐以后,每个函数都要各写一遍递归,哪个只写了一层,哪个就悄悄算错。
13.1 一个场景
Section titled “13.1 一个场景”你在一家卖坚果和茶叶的电商公司写后端。店里卖的东西有两种:单品 Product,有名字、价格(元)和重量(克);套餐 Bundle,把几个单品捆在一起卖,比如“坚果套装”里是碧根果、夏威夷果、巴旦木,套餐价就是里面单品价格之和。
购物车里的一行可能是单品,也可能是套餐。用到购物车的代码有三处,分在三个文件里,由不同的人维护:
- 结算页
cart_total(cart.py):算合计金额。 - 运费模块
cart_weight(shipping.py):称出整单重量,交给shipping_fee计费。规则是首重 1 千克 10 元,之后每千克 5 元,不足 1 千克按 1 千克算。 - 订单清单
receipt(receipt.py):每件一行,套餐下面缩进列出里面的单品,给仓库拣货,也给用户看。
春节快到了,运营在筹备一个“年货礼盒”:礼盒里装一个坚果套装、一个茶叶套装和一张贺卡,整个礼盒打九折。下图是运营画的草图。这件事还没排期,眼下的套餐里都只有单品。
图里每个节点都标着价格和重量。套装的数字是里面单品之和;礼盒是 170 + 220 + 10 = 400 元再打九折,得 360 元,重量 1400 + 607 + 50 = 2057 克。后面代码打印的数字都可以拿这张图来对。
13.2 先按直觉写
Section titled “13.2 先按直觉写”套餐里只有单品,那就让 Bundle 持有一个 list[Product]。三个函数各自遍历购物车,遇到套餐就把里面的单品过一遍:
import mathfrom dataclasses import dataclass
@dataclassclass Product: # 单品 name: str price: int # 元 weight: int # 克
@dataclassclass Bundle: # 套餐:里面都是单品,套餐价就是单品价格之和 name: str products: list[Product]
CartItem = Product | Bundle # 购物车里的一行
# cart.py:结算页的合计def cart_total(items: list[CartItem]) -> int: total = 0 for item in items: if isinstance(item, Bundle): for p in item.products: total += p.price else: total += item.price return total
# shipping.py:运费模块,先称重再计费def cart_weight(items: list[CartItem]) -> int: weight = 0 for item in items: if isinstance(item, Bundle): for p in item.products: weight += p.weight else: weight += item.weight return weight
def shipping_fee(grams: int) -> int: # 首重 1 千克 10 元,续重每千克 5 元,不足 1 千克按 1 千克 return 10 + 5 * math.ceil(max(0, grams - 1000) / 1000)
# receipt.py:订单清单def receipt(items: list[CartItem]) -> list[str]: lines: list[str] = [] for item in items: if isinstance(item, Bundle): price = sum(p.price for p in item.products) weight = sum(p.weight for p in item.products) lines.append(f"{item.name} {price} 元 {weight} 克") for p in item.products: lines.append(f" {p.name} {p.price} 元 {p.weight} 克") else: lines.append(f"{item.name} {item.price} 元 {item.weight} 克") return lines
if __name__ == "__main__": nuts = Bundle("坚果套装", [Product("碧根果", 68, 500), Product("夏威夷果", 58, 500), Product("巴旦木", 44, 400)]) cart: list[CartItem] = [nuts, Product("保温杯", 99, 450)] print("\n".join(receipt(cart))) grams = cart_weight(cart) print(f"合计 {cart_total(cart)} 元,重 {grams} 克,运费 {shipping_fee(grams)} 元")
# 输出:# 坚果套装 170 元 1400 克# 碧根果 68 元 500 克# 夏威夷果 58 元 500 克# 巴旦木 44 元 400 克# 保温杯 99 元 450 克# 合计 269 元,重 1850 克,运费 15 元TS 版结构一样,用 instanceof 区分单品和套餐:
class Product { // 单品 constructor( readonly name: string, readonly price: number, // 元 readonly weight: number, // 克 ) {}}
class Bundle { // 套餐:里面都是单品,套餐价就是单品价格之和 constructor(readonly name: string, readonly products: Product[]) {}}
type CartItem = Product | Bundle; // 购物车里的一行
// cart.ts:结算页的合计function cartTotal(items: CartItem[]): number { let total = 0; for (const item of items) { if (item instanceof Bundle) { for (const p of item.products) total += p.price; } else { total += item.price; } } return total;}
// shipping.ts:运费模块,先称重再计费function cartWeight(items: CartItem[]): number { let weight = 0; for (const item of items) { if (item instanceof Bundle) { for (const p of item.products) weight += p.weight; } else { weight += item.weight; } } return weight;}
function shippingFee(grams: number): number { // 首重 1 千克 10 元,续重每千克 5 元,不足 1 千克按 1 千克 return 10 + 5 * Math.ceil(Math.max(0, grams - 1000) / 1000);}
// receipt.ts:订单清单function receipt(items: CartItem[]): string[] { const lines: string[] = []; for (const item of items) { if (item instanceof Bundle) { const price = item.products.reduce((s, p) => s + p.price, 0); const weight = item.products.reduce((s, p) => s + p.weight, 0); lines.push(`${item.name} ${price} 元 ${weight} 克`); for (const p of item.products) lines.push(` ${p.name} ${p.price} 元 ${p.weight} 克`); } else { lines.push(`${item.name} ${item.price} 元 ${item.weight} 克`); } } return lines;}
const nuts = new Bundle("坚果套装", [ new Product("碧根果", 68, 500), new Product("夏威夷果", 58, 500), new Product("巴旦木", 44, 400),]);const cart: CartItem[] = [nuts, new Product("保温杯", 99, 450)];console.log(receipt(cart).join("\n"));const grams = cartWeight(cart);console.log(`合计 ${cartTotal(cart)} 元,重 ${grams} 克,运费 ${shippingFee(grams)} 元`);
// 输出:// 坚果套装 170 元 1400 克// 碧根果 68 元 500 克// 夏威夷果 58 元 500 克// 巴旦木 44 元 400 克// 保温杯 99 元 450 克// 合计 269 元,重 1850 克,运费 15 元三个函数各有一个 isinstance 分支。坚果套装 170 元加保温杯 99 元,合计 269 元;1850 克比首重多 850 克,按 1 千克续重,运费 15 元。套餐只有一层的时候,这样写清楚直接,大多数人第一版都这么写。
13.3 需求变了
Section titled “13.3 需求变了”年货礼盒排上了,要求是:套餐里可以再放套餐(年货礼盒里有坚果套装和茶叶套装);套餐可以整体打折(年货礼盒九折);订单清单按层缩进,套餐那一行显示打折后的价格和总重量。
要改的地方有四处:
Bundle:字段从products: list[Product]改成items,里面既可以是单品,也可以是套餐;再加一个percent,90 就是九折。字段一改名,三个函数里的item.products都要跟着改。cart_total(cart.py):只往下走一层不够了,要改成递归。抽出一个price_of(item),遇到套餐就对每个孩子调自己,加完再打本层的折。折扣规则写在这里。cart_weight(shipping.py):同样要改成递归。运费模块归另一个组维护。receipt(receipt.py):改成递归,每深一层多缩进两格;套餐那一行要显示价格和重量。价格可以调 cart.py 的price_of,重量 cart.py 里没有现成的,只好自己再写一个递归weight_of。
改完的样子(只列改动,Product、CartItem、shipping_fee 和 13.2 相同):
@dataclassclass Bundle: name: str items: list["Product | Bundle"] # ① 原来叫 products,只能放单品 percent: int = 100 # ① 整体折扣,90 就是九折
# cart.pydef price_of(item: Product | Bundle) -> int: # ② 递归;折扣规则写在这里 if isinstance(item, Bundle): return sum(price_of(c) for c in item.items) * item.percent // 100 return item.price
def cart_total(items: list[CartItem]) -> int: return sum(price_of(i) for i in items)
# shipping.py(运费组改的)def cart_weight(items: list[CartItem]) -> int: # ③ 只多看了一层 weight = 0 for item in items: if isinstance(item, Bundle): for child in item.items: if isinstance(child, Product): # 类型检查说 Bundle 没有 weight,加了这句才过 weight += child.weight else: weight += item.weight return weight
# receipt.pydef weight_of(item: Product | Bundle) -> int: # ④ cart.py 里没有现成的,又写一个递归 if isinstance(item, Bundle): return sum(weight_of(c) for c in item.items) return item.weight
def receipt(items: list[CartItem], indent: int = 0) -> list[str]: # ④ 递归,每层多缩进两格 lines: list[str] = [] pad = " " * indent for item in items: if isinstance(item, Bundle): off = f"({item.percent / 10:g} 折)" if item.percent < 100 else "" lines.append(f"{pad}{item.name}{off} {price_of(item)} 元 {weight_of(item)} 克") lines += receipt(item.items, indent + 1) else: lines.append(f"{pad}{item.name} {item.price} 元 {item.weight} 克") return lines第 3 处是事故现场。运费组的同事把 p.weight 改成 child.weight 时,类型检查报 Bundle 没有 weight 属性,他加了一句 isinstance(child, Product),检查通过了,拿老套餐测也都对。可年货礼盒里的两个套装正是被这一句跳过的孩子,它们的重量算成了 0。把上面几段换进 before.py,main 里放进礼盒和保温杯:
if __name__ == "__main__": nuts = Bundle("坚果套装", [Product("碧根果", 68, 500), Product("夏威夷果", 58, 500), Product("巴旦木", 44, 400)]) tea = Bundle("茶叶套装", [Product("龙井", 128, 250), Product("普洱", 92, 357)]) box = Bundle("年货礼盒", [nuts, tea, Product("贺卡", 10, 50)], percent=90) cart: list[CartItem] = [box, Product("保温杯", 99, 450)] print("\n".join(receipt(cart))) grams = cart_weight(cart) print(f"合计 {cart_total(cart)} 元,重 {grams} 克,运费 {shipping_fee(grams)} 元")运行结果:
年货礼盒(9 折) 360 元 2057 克 坚果套装 170 元 1400 克 碧根果 68 元 500 克 夏威夷果 58 元 500 克 巴旦木 44 元 400 克 茶叶套装 220 元 607 克 龙井 128 元 250 克 普洱 92 元 357 克 贺卡 10 元 50 克保温杯 99 元 450 克合计 459 元,重 500 克,运费 10 元清单和金额都对:礼盒 360 元加保温杯 99 元,459 元。错的是最后一行的重量。清单上礼盒 2057 克加保温杯 450 克,一共 2507 克,比首重多 1507 克,续重按 2 千克算,应收 10 + 5 × 2 = 20 元;cart_weight 只数到了贺卡和保温杯,500 克,收了 10 元。这一单少收了 10 元运费。同一个礼盒,清单说 2057 克,运费模块只数到 50 克,两边各写各的递归,谁也不知道对方算出了什么。TS 版要改的是同样的四处,事故也一样。
问题的形状是:单品和套餐接口不同,每个用到购物车的函数都要自己判断类型、自己往下走;树多一层,每个函数都得各自改成递归,走树的代码写了三遍,只写对一层的那一遍照样能跑。
图中 ①–④ 就是上面四处。边框加粗的 cart_weight 只看了礼盒的第一层孩子,两个套装在那里被跳过,标着“跳过”的那条虚线就是丢掉的 2007 克。
13.4 用组合模式重写
Section titled “13.4 用组合模式重写”换个思路:别让购物车去问“这是单品还是套餐”。单品和套餐实现同一个接口 Item,都能回答 price()、weight()、describe(indent)。单品直接给出自己的数;套餐手里是一组 Item,把同样的问题转给每个孩子,再把答案汇总。孩子是单品还是套餐,套餐不用管,反正都是 Item。走树的递归因此只写一次,写在 Bundle 的方法里。
import mathfrom collections.abc import Iterablefrom typing import Protocol
class Item(Protocol): # 购物车里的一行:单品和套餐都是它 def price(self) -> int: ... def weight(self) -> int: ... def describe(self, indent: int = 0) -> list[str]: ...
class Product: # 叶子:自己就是答案 def __init__(self, name: str, price: int, weight: int) -> None: self.name = name self._price = price # 元 self._weight = weight # 克
def price(self) -> int: return self._price
def weight(self) -> int: return self._weight
def describe(self, indent: int = 0) -> list[str]: return [f"{' ' * indent}{self.name} {self._price} 元 {self._weight} 克"]
class Bundle: # 组合:手里是一组 Item,把问题转给每个孩子再汇总 def __init__(self, name: str, items: Iterable[Item] = (), percent: int = 100) -> None: self.name = name self.percent = percent # 整体折扣,90 就是九折 self.items: list[Item] = list(items)
def add(self, item: Item) -> None: # 只有套餐能往里放东西 self.items.append(item)
def price(self) -> int: # 先把孩子的价格加起来,再打本层的折(示例价格都能整除) return sum(i.price() for i in self.items) * self.percent // 100
def weight(self) -> int: return sum(i.weight() for i in self.items)
def describe(self, indent: int = 0) -> list[str]: off = f"({self.percent / 10:g} 折)" if self.percent < 100 else "" lines = [f"{' ' * indent}{self.name}{off} {self.price()} 元 {self.weight()} 克"] for i in self.items: lines += i.describe(indent + 1) return lines
class Cart: # 购物车只认 Item,没有一句类型判断 def __init__(self) -> None: self.items: list[Item] = []
def add(self, item: Item) -> None: self.items.append(item)
def total_price(self) -> int: return sum(i.price() for i in self.items)
def total_weight(self) -> int: return sum(i.weight() for i in self.items)
def receipt(self) -> list[str]: return [line for i in self.items for line in i.describe()]
def shipping_fee(grams: int) -> int: # 和 13.2 相同 return 10 + 5 * math.ceil(max(0, grams - 1000) / 1000)
def new_year_box() -> Bundle: nuts = Bundle("坚果套装", [Product("碧根果", 68, 500), Product("夏威夷果", 58, 500), Product("巴旦木", 44, 400)]) tea = Bundle("茶叶套装", [Product("龙井", 128, 250), Product("普洱", 92, 357)]) box = Bundle("年货礼盒", [nuts, tea], percent=90) box.add(Product("贺卡", 10, 50)) return box
if __name__ == "__main__": cart = Cart() cart.add(new_year_box()) cart.add(Product("保温杯", 99, 450)) print("\n".join(cart.receipt())) grams = cart.total_weight() print(f"合计 {cart.total_price()} 元,重 {grams} 克,运费 {shipping_fee(grams)} 元")
group = Bundle("团购箱", [new_year_box(), new_year_box()], percent=95) # 再套一层,不改任何类 print(f"{group.name}({group.percent / 10:g} 折):{group.price()} 元 {group.weight()} 克")
# 输出:# 年货礼盒(9 折) 360 元 2057 克# 坚果套装 170 元 1400 克# 碧根果 68 元 500 克# 夏威夷果 58 元 500 克# 巴旦木 44 元 400 克# 茶叶套装 220 元 607 克# 龙井 128 元 250 克# 普洱 92 元 357 克# 贺卡 10 元 50 克# 保温杯 99 元 450 克# 合计 459 元,重 2507 克,运费 20 元# 团购箱(9.5 折):684 元 4114 克TS 版一样,接口写成 interface,打折后用 Math.floor 取整,和 Python 的 // 对应:
interface Item { // 购物车里的一行:单品和套餐都是它 price(): number; weight(): number; describe(indent?: number): string[];}
class Product implements Item { // 叶子:自己就是答案 constructor( readonly name: string, private readonly _price: number, // 元 private readonly _weight: number, // 克 ) {}
price(): number { return this._price; }
weight(): number { return this._weight; }
describe(indent = 0): string[] { return [`${" ".repeat(indent)}${this.name} ${this._price} 元 ${this._weight} 克`]; }}
class Bundle implements Item { // 组合:手里是一组 Item,把问题转给每个孩子再汇总 readonly items: Item[];
constructor(readonly name: string, items: Item[] = [], readonly percent = 100) { this.items = [...items]; }
add(item: Item): void { // 只有套餐能往里放东西 this.items.push(item); }
price(): number { // 先把孩子的价格加起来,再打本层的折(示例价格都能整除) const sum = this.items.reduce((s, i) => s + i.price(), 0); return Math.floor((sum * this.percent) / 100); }
weight(): number { return this.items.reduce((s, i) => s + i.weight(), 0); }
describe(indent = 0): string[] { const off = this.percent < 100 ? `(${this.percent / 10} 折)` : ""; const lines = [`${" ".repeat(indent)}${this.name}${off} ${this.price()} 元 ${this.weight()} 克`]; for (const i of this.items) lines.push(...i.describe(indent + 1)); return lines; }}
class Cart { // 购物车只认 Item,没有一句类型判断 readonly items: Item[] = [];
add(item: Item): void { this.items.push(item); }
totalPrice(): number { return this.items.reduce((s, i) => s + i.price(), 0); }
totalWeight(): number { return this.items.reduce((s, i) => s + i.weight(), 0); }
receipt(): string[] { return this.items.flatMap((i) => i.describe()); }}
function shippingFee(grams: number): number { // 和 13.2 相同 return 10 + 5 * Math.ceil(Math.max(0, grams - 1000) / 1000);}
function newYearBox(): Bundle { const nuts = new Bundle("坚果套装", [ new Product("碧根果", 68, 500), new Product("夏威夷果", 58, 500), new Product("巴旦木", 44, 400), ]); const tea = new Bundle("茶叶套装", [new Product("龙井", 128, 250), new Product("普洱", 92, 357)]); const box = new Bundle("年货礼盒", [nuts, tea], 90); box.add(new Product("贺卡", 10, 50)); return box;}
const cart = new Cart();cart.add(newYearBox());cart.add(new Product("保温杯", 99, 450));console.log(cart.receipt().join("\n"));const grams = cart.totalWeight();console.log(`合计 ${cart.totalPrice()} 元,重 ${grams} 克,运费 ${shippingFee(grams)} 元`);
const group = new Bundle("团购箱", [newYearBox(), newYearBox()], 95); // 再套一层,不改任何类console.log(`${group.name}(${group.percent / 10} 折):${group.price()} 元 ${group.weight()} 克`);
// 输出:// 年货礼盒(9 折) 360 元 2057 克// 坚果套装 170 元 1400 克// 碧根果 68 元 500 克// 夏威夷果 58 元 500 克// 巴旦木 44 元 400 克// 茶叶套装 220 元 607 克// 龙井 128 元 250 克// 普洱 92 元 357 克// 贺卡 10 元 50 克// 保温杯 99 元 450 克// 合计 459 元,重 2507 克,运费 20 元// 团购箱(9.5 折):684 元 4114 克看标红的两个类:Item 是新加的接口,新加的 Cart 只连着它。Bundle 那条带菱形的线指向的也是 Item,不是 Product:套餐里能放什么由这条线决定,单品和套餐都行。
看顺序图:Cart 对礼盒只调了一次 weight();礼盒对三个孩子各调一次,两个套装再对自己的单品调,单品直接返回自己的克数。返回的箭头一层层往上加:坚果套装 1400,茶叶套装 607,礼盒 2057,加上保温杯 450,整单 2507 克,和 13.1 的图对得上,运费 20 元。
逐条对照 13.3 的四处:
- 第 1 处,
Bundle的字段:items的类型是list[Item],放单品、放套餐都行,不用再写Product | Bundle。折扣只在Bundle.price()里,先加孩子,再打本层的折:礼盒 400 元打九折得 360 元,坚果套装自己没打折,清单上仍是 170 元。 - 第 2、3 处,合计和称重:
Cart.total_price()和total_weight()各是一行sum,没有类型判断,也没有循环套循环。“只递归了一层”在这里写不出来,因为Cart根本不往下走,往下走的是每个Bundle自己。 - 第 4 处,清单:缩进由
describe(indent)往下传,每层加一;Bundle那一行用的是自己的price()和weight(),和合计、运费走的是同一份递归,不会再一边 2057 克、一边 50 克。 - 再多一层:最后两行建了一个团购箱,里面两个年货礼盒,再打 95 折,720 元打完是 684 元,重量 2 × 2057 = 4114 克。树变成了四层,没有改任何一个类。
要说清楚的是代价。加一种新节点很便宜:以后有“赠品”,写一个实现 Item 的类,Cart 和 Bundle 都不动。加一种新操作就贵了:仓库要按体积选纸箱,得在 Item 里加 volume(),再在 Product、Bundle 里各实现一遍,改三处;在 13.3 的写法里,这只是多写一个带 isinstance 的递归函数。组合模式把“按类型分支”从各个函数里搬进了各个类,新节点和新操作,总有一种要改多处。节点种类固定、新操作不断加的场合,第 26 章的访问者是另一种分法。
还有一件 Bundle 不管的事:add 不检查环。a.add(b) 之后又 b.add(a),a.price() 会一直递归下去,Python 报 RecursionError,TS 报栈溢出。套餐是运营在后台配的,真实系统要在保存时检查一下新孩子里有没有自己。
13.5 模式定义
Section titled “13.5 模式定义”GoF 对组合的定义是:将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。
“部分-整体”就是礼盒和里面的套装、套装和里面的单品:整体由部分组成,部分本身又可能是个整体。“一致性”说的是调用方的写法:问一件单品多重和问一整个礼盒多重,都是 item.weight()。做到这一点靠两件事:叶子和容器实现同一个接口;容器持有的孩子类型是这个接口,而不是某个具体的叶子类。
图里 Composite 指回 Component 的那条线是关键:孩子的类型是接口,所以孩子还可以是 Composite,树才能任意深。
| 角色 | 在本章案例里是谁 | 职责 |
|---|---|---|
| 组件(Component) | Item |
叶子和容器共同的接口:price、weight、describe |
| 叶子(Leaf) | Product |
没有孩子,自己直接给出答案 |
| 组合(Composite) | Bundle |
持有一组 Item,把调用转给每个孩子再汇总,本层还可以加点东西(比如折扣) |
| 客户(Client) | Cart |
只用 Item,不知道手里是一件单品还是一棵树 |
add、remove 放在哪里,有两种做法。透明式(transparent)把它们放进 Component 接口,叶子也有 add,只是一调就抛错或者什么都不做;好处是调用方连“能不能往里放”都不用判断,代价是 product.add(x) 能通过类型检查,到运行时才出错。安全式(safe)只在 Composite 上放 add、remove,叶子根本没有这两个方法,product.add(x) 一写出来类型检查就报错;代价是想往里放东西时,手里得拿着 Bundle,而不是 Item。GoF 原书的结构图把这些方法画在 Component 上,是透明式;本书用安全式,上图也按安全式画。本章往套餐里放东西的只有配套餐的代码,它本来就知道自己在配 Bundle。
常见场景:
- 文件目录。文件和目录都有大小,目录的大小是里面所有东西之和。
- UI 组件树。窗口里有面板,面板里有按钮和别的面板,布局、绘制时对每个节点调同一个方法;13.6 例二的 DOM 就是这一类。
- 组织架构。部门里有人,也有子部门,算部门人数、人力成本都是往下汇总。
- 表达式树。
1 + 2 * 3里数字是叶子,加号、乘号节点各持有两个子表达式,evaluate()递归求值。 - 菜单。菜单项和子菜单放在同一个菜单里,渲染时一样对待。
13.6 真实系统里的例子
Section titled “13.6 真实系统里的例子”例一:ElementTree 的 Element(Python)
Section titled “例一:ElementTree 的 Element(Python)”Python 标准库的 xml.etree.ElementTree 把 XML 解析成一棵 Element 树。每个 Element 既是一个节点,有标签名 tag 和属性 attrib;又是一个容器,可以像列表一样 len()、按下标取孩子、append。它不分叶子类和容器类,没有孩子的元素就是叶子。按文档的说法,Element.iter(tag) 以当前元素为根,按文档顺序(深度优先)依次给出它自己和所有后代里标签匹配的元素;findall(".//item") 这种带 .// 的路径,也是在整棵子树里找。这些方法对根元素和对任何一个子元素都一样能调。下面模拟一个只有标签、名字和孩子的 Element,再拿真正的 ElementTree 解析同一张订单对一下:
# 模拟 xml.etree.ElementTree.Element:每个元素既是节点,又是装子元素的容器。# 简化:真实的 Element 还有 text、tail,名字这类数据放在 attrib 里;这里只留 tag、name 和孩子。import xml.etree.ElementTree as ETfrom collections.abc import Iterator
class Element: def __init__(self, tag: str, name: str = "", children: list["Element"] | None = None) -> None: self.tag, self.name = tag, name self.children = children or []
def append(self, child: "Element") -> None: # 真实的 Element.append 也叫这个名字 self.children.append(child)
def iter(self, tag: str | None = None) -> Iterator["Element"]: # 对应 Element.iter(tag) if tag is None or self.tag == tag: # 先给出自己 yield self for child in self.children: # 再深度优先地走每个孩子 yield from child.iter(tag)
nuts = Element("bundle", "坚果套装", [Element("item", n) for n in ("碧根果", "夏威夷果", "巴旦木")])tea = Element("bundle", "茶叶套装", [Element("item", n) for n in ("龙井", "普洱")])box = Element("bundle", "年货礼盒", [nuts, tea])box.append(Element("item", "贺卡"))order = Element("order", children=[box, Element("item", "保温杯")])print(f"整单 item:{sum(1 for _ in order.iter('item'))} 个")print(f"坚果套装里 item:{sum(1 for _ in nuts.iter('item'))} 个")print("bundle 依次是:" + "、".join(e.name for e in order.iter("bundle")))
# 用真正的 ElementTree 解析同一张订单root = ET.fromstring( "<order><bundle name='年货礼盒'><bundle name='坚果套装'><item/><item/><item/></bundle>" "<bundle name='茶叶套装'><item/><item/></bundle><item/></bundle><item/></order>")print(f"ElementTree:iter 数出 {sum(1 for _ in root.iter('item'))} 个," f"findall('.//item') 找到 {len(root.findall('.//item'))} 个")print("bundle 依次是:" + "、".join(e.get("name", "") for e in root.iter("bundle")))
# 输出:# 整单 item:7 个# 坚果套装里 item:3 个# bundle 依次是:年货礼盒、坚果套装、茶叶套装# ElementTree:iter 数出 7 个,findall('.//item') 找到 7 个# bundle 依次是:年货礼盒、坚果套装、茶叶套装Element 一个类同时扮演了本章的 Product 和 Bundle:孩子列表为空就是叶子。iter 和 Bundle.weight() 是同一个递归形状,先处理自己,再把同一个调用交给每个孩子;区别是它用生成器把元素一个个交出去,在哪里汇总由外面的代码决定。
例二:DOM 的 textContent 和 contains(TypeScript)
Section titled “例二:DOM 的 textContent 和 contains(TypeScript)”浏览器把网页解析成一棵 DOM 树,最常见的节点是元素节点和文本节点,它们都继承自 Node。在元素上读 Node.textContent,得到的是所有后代文本节点按顺序拼起来的字符串(注释不算);在文本节点上读,就是它自己的文字。node.contains(other) 在 other 是 node 自己或它的后代时返回 true。调用方对一个 <div>、一个 <li> 还是一段文字,写法都一样。下面用两个类模拟:
// 模拟浏览器 DOM 的节点树:文本节点是叶子,元素节点装着子节点,两者都有 textContent 和 contains。// 简化:真实的 Node 还有注释节点、parentNode 等,textContent 也能写;这里只读,只有两种节点。interface DomNode { readonly textContent: string; // 对应 Node.textContent contains(other: DomNode): boolean; // 对应 Node.contains:other 是自己或后代时为 true}
class TextNode implements DomNode { constructor(private readonly data: string) {} get textContent(): string { return this.data; } contains(other: DomNode): boolean { return other === this; }}
class ElementNode implements DomNode { constructor(readonly tagName: string, private readonly childNodes: DomNode[] = []) {} appendChild(child: DomNode): DomNode { this.childNodes.push(child); return child; } get textContent(): string { // 把所有后代的文字按顺序拼起来 return this.childNodes.map((c) => c.textContent).join(""); } contains(other: DomNode): boolean { return other === this || this.childNodes.some((c) => c.contains(other)); }}
const tea = new ElementNode("li", [new TextNode("茶叶套装")]);const ul = new ElementNode("ul", [new ElementNode("li", [new TextNode("坚果套装")])]);ul.appendChild(tea);const card = new ElementNode("div", [new ElementNode("h3", [new TextNode("年货礼盒")]), ul]);const cup = new ElementNode("div", [new TextNode("保温杯")]);console.log(card.textContent);console.log(ul.textContent);console.log(card.contains(tea), card.contains(cup), tea.contains(card));
// 输出:// 年货礼盒坚果套装茶叶套装// 坚果套装茶叶套装// true false falseTextNode 是叶子,ElementNode 是组合,DomNode 是组件接口。第一行输出也带出一个常见的坑:textContent 只是把文字首尾相接,“年货礼盒坚果套装茶叶套装”中间没有空格,拿它做搜索摘要时要自己处理。React 的元素树大致也是这个思路:一个组件可以直接返回 <li>,也可以返回包着别的组件的组件,渲染时一层层展开,写组件的人不用关心孩子是哪一种。
13.7 什么时候别用
Section titled “13.7 什么时候别用”- 数据本来不是树。套餐永远只有一层时(13.2 就是这样),为了用模式造一个
Item接口和Product、Bundle、Cart三个类,读代码的人要跳过两个类才看到一个sum。还有一种是数据其实是图:一个商品同时属于“坚果”和“年货”两个分类,硬塞进树里,要么复制一份节点,改价时两份只改了一份;要么让同一个对象挂在两处,算“全店商品总价”时它被加两次。 - 叶子和容器能做的事差太多。比如目录要能“列出孩子”“新建子目录”“设置继承的权限”,文件要能“读”“写”“截断”,全塞进一个接口,一半方法对另一半节点没有意义,只能抛错或返回空,调用方又得先判断类型,等于回到了 13.2。只把真正共同的操作(比如
size())放进接口,其余留在各自的类上;共同的操作一个都找不出来,就别用组合。 - 大树上反复从头算。
Bundle.price()每次都把整棵子树走一遍,不存结果。商品目录有 10 万个节点,后台在每个节点旁边显示小计,每个小计各算各的子树,一个节点在自己和每个祖先的小计里各被算一次:多数节点在第 6 层时,刷新一次页面大约 60 万次调用;树退化成一条长链(每层只有一个子目录)时,是 1 + 2 + … + 10 万,约 50 亿次。改一个单品的价格,全部重算。办法有两种:在节点里缓存小计,孩子变化时让它和所有祖先的缓存失效(节点要记住父节点);或者需要全部小计时自底向上走一遍,每个节点只算一次,10 万个节点就是 10 万次。本章的describe其实就有这个问题:礼盒那一行调price()时已经走过一遍子树,往下打印两个套装时又各走一遍。三层的小礼盒无所谓,大目录就得改。
13.8 容易混淆的模式
Section titled “13.8 容易混淆的模式”| 模式 | 意图 | 区别 |
|---|---|---|
| 组合(Composite) | 把对象组织成树,单个和整体一样对待 | 容器持有多个孩子,目的是让调用方不分单个和整体;本层可以顺带加点东西(如折扣),主要工作是汇总 |
| 装饰器(Decorator) | 不改原对象,给它叠加功能 | 也实现同一个接口,也能一层套一层;但每层只包一个对象,目的是加功能,见第 10 章 |
| 迭代器(Iterator) | 不暴露内部结构地逐个访问元素 | 组合定义树的形状和每个节点怎么回答问题;想在树外面一个个拿节点,用迭代器,见第 22 章 |
装饰器和组合都是“持有同一个接口的对象,自己也实现这个接口”,画出来很像。分清它们看两点:手里有几个孩子,套这一层为了什么。拿本章举例:要给礼盒加“礼品包装”,多收 15 元、多 200 克,可以写一个 GiftWrapped(item)。它只包一个 Item,price() 返回 item.price() + 15,这是装饰器;去掉它,礼盒少了包装这项功能。Bundle 手里是一组孩子,它存在的目的是让“一组”能当“一个”用。两者也能混着用:GiftWrapped(new_year_box()) 照样是 Item,放得进购物车,也放得进团购箱。
和迭代器的分别在于谁来走树。本章的操作都写成了节点上的方法,每个节点自己往下调;13.6 例一的 iter() 是另一种做法:树只负责按顺序把节点交出来,比如“找出所有超过 1 千克的单品”,写在外面的循环里,不用给每个节点类加方法。遍历树的几种写法(递归生成器、显式栈、深度优先和广度优先)放在第 22 章的练习。
13.9 练一练
Section titled “13.9 练一练”写一个文件系统模型:File(name, size) 是叶子,Directory(name, children) 是组合,两者实现同一个接口,都有:
size():文件返回自己的字节数,目录返回里面所有东西之和。find(name):返回所有名字等于name的文件或目录的路径(list[str]),路径从调用的那个节点算起,用/连接。
用三层目录验证:project 下有 README.md(1200 字节)、src、docs;src 下有 main.py(3400)和 utils;utils 下有 helpers.py(2100)和 README.md(800);docs 下有 README.md(1500)和 guide.md(5000)。打印 project 和 src 的大小,以及在 project 上 find("README.md")、find("utils")、find("missing.txt") 的结果。Python 和 TS 各写一版。
提示:find 要知道“从起点到我”的路径,可以多传一个前缀参数,目录往下调时把自己的路径接上去。add 只放在 Directory 上。
13.10 小结
Section titled “13.10 小结”- 组合让叶子和容器实现同一个接口,容器持有的孩子类型就是这个接口;调用方对一件单品和一整棵树,写法一样。
- 走树的递归只写一次,写在容器的方法里;调用方没有类型判断,也就写不出“只递归了一层”的函数。树多一层、加一种节点,不用改已有的类。
- 代价是每加一种操作,要在接口、叶子、容器上各加一个方法;操作多、节点种类固定时,看第 22 章的迭代器或第 26 章的访问者,它们把遍历搬到树外。
- 本书用安全式:
add、remove只在容器上,叶子调不到;透明式把它们放进接口,调用方不必分类型,代价是错误推迟到运行时。 - 和装饰器的分别:装饰器只包一个对象,为了加功能;组合持有一组孩子,为了把一组当一个用。