跳转到内容

第 13 章 · 组合(Composite)

一句话:让单个对象和装着一组对象的容器实现同一个接口,容器把调用转给每个孩子再汇总,调用方不用分清手里是哪一种。 解决的痛:单品和套餐接口不同,每个用到购物车的函数都要写 isinstance 分支;套餐能套套餐以后,每个函数都要各写一遍递归,哪个只写了一层,哪个就悄悄算错。

你在一家卖坚果和茶叶的电商公司写后端。店里卖的东西有两种:单品 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 克。后面代码打印的数字都可以拿这张图来对。

套餐里只有单品,那就让 Bundle 持有一个 list[Product]。三个函数各自遍历购物车,遇到套餐就把里面的单品过一遍:

import math
from dataclasses import dataclass
@dataclass
class Product: # 单品
name: str
price: int # 元
weight: int # 克
@dataclass
class 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 元。套餐只有一层的时候,这样写清楚直接,大多数人第一版都这么写。

年货礼盒排上了,要求是:套餐里可以再放套餐(年货礼盒里有坚果套装和茶叶套装);套餐可以整体打折(年货礼盒九折);订单清单按层缩进,套餐那一行显示打折后的价格和总重量。

要改的地方有四处:

  1. Bundle:字段从 products: list[Product] 改成 items,里面既可以是单品,也可以是套餐;再加一个 percent,90 就是九折。字段一改名,三个函数里的 item.products 都要跟着改。
  2. cart_total(cart.py):只往下走一层不够了,要改成递归。抽出一个 price_of(item),遇到套餐就对每个孩子调自己,加完再打本层的折。折扣规则写在这里。
  3. cart_weight(shipping.py):同样要改成递归。运费模块归另一个组维护。
  4. receipt(receipt.py):改成递归,每深一层多缩进两格;套餐那一行要显示价格和重量。价格可以调 cart.py 的 price_of,重量 cart.py 里没有现成的,只好自己再写一个递归 weight_of。

改完的样子(只列改动,Product、CartItem、shipping_fee 和 13.2 相同):

@dataclass
class Bundle:
name: str
items: list["Product | Bundle"] # ① 原来叫 products,只能放单品
percent: int = 100 # ① 整体折扣,90 就是九折
# cart.py
def 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.py
def 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 克。

换个思路:别让购物车去问“这是单品还是套餐”。单品和套餐实现同一个接口 Item,都能回答 price()、weight()、describe(indent)。单品直接给出自己的数;套餐手里是一组 Item,把同样的问题转给每个孩子,再把答案汇总。孩子是单品还是套餐,套餐不用管,反正都是 Item。走树的递归因此只写一次,写在 Bundle 的方法里。

import math
from collections.abc import Iterable
from 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 克

组合版:Product 和 Bundle 都实现 Item,Bundle 持有一组 Item

看标红的两个类:Item 是新加的接口,新加的 Cart 只连着它。Bundle 那条带菱形的线指向的也是 Item,不是 Product:套餐里能放什么由这条线决定,单品和套餐都行。

一次 total_weight:调用顺着树往下,重量顺着树往上加

看顺序图: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 报栈溢出。套餐是运营在后台配的,真实系统要在保存时检查一下新孩子里有没有自己。

GoF 对组合的定义是:将对象组合成树形结构以表示“部分-整体”的层次结构。组合模式使得用户对单个对象和组合对象的使用具有一致性。

“部分-整体”就是礼盒和里面的套装、套装和里面的单品:整体由部分组成,部分本身又可能是个整体。“一致性”说的是调用方的写法:问一件单品多重和问一整个礼盒多重,都是 item.weight()。做到这一点靠两件事:叶子和容器实现同一个接口;容器持有的孩子类型是这个接口,而不是某个具体的叶子类。

组合的结构:Leaf 和 Composite 实现同一个 Component,Composite 持有一组 Component

图里 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() 递归求值。
  • 菜单。菜单项和子菜单放在同一个菜单里,渲染时一样对待。

例一: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 ET
from 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 false

TextNode 是叶子,ElementNode 是组合,DomNode 是组件接口。第一行输出也带出一个常见的坑:textContent 只是把文字首尾相接,“年货礼盒坚果套装茶叶套装”中间没有空格,拿它做搜索摘要时要自己处理。React 的元素树大致也是这个思路:一个组件可以直接返回 <li>,也可以返回包着别的组件的组件,渲染时一层层展开,写组件的人不用关心孩子是哪一种。

  • 数据本来不是树。套餐永远只有一层时(13.2 就是这样),为了用模式造一个 Item 接口和 Product、Bundle、Cart 三个类,读代码的人要跳过两个类才看到一个 sum。还有一种是数据其实是图:一个商品同时属于“坚果”和“年货”两个分类,硬塞进树里,要么复制一份节点,改价时两份只改了一份;要么让同一个对象挂在两处,算“全店商品总价”时它被加两次。
  • 叶子和容器能做的事差太多。比如目录要能“列出孩子”“新建子目录”“设置继承的权限”,文件要能“读”“写”“截断”,全塞进一个接口,一半方法对另一半节点没有意义,只能抛错或返回空,调用方又得先判断类型,等于回到了 13.2。只把真正共同的操作(比如 size())放进接口,其余留在各自的类上;共同的操作一个都找不出来,就别用组合。
  • 大树上反复从头算。Bundle.price() 每次都把整棵子树走一遍,不存结果。商品目录有 10 万个节点,后台在每个节点旁边显示小计,每个小计各算各的子树,一个节点在自己和每个祖先的小计里各被算一次:多数节点在第 6 层时,刷新一次页面大约 60 万次调用;树退化成一条长链(每层只有一个子目录)时,是 1 + 2 + … + 10 万,约 50 亿次。改一个单品的价格,全部重算。办法有两种:在节点里缓存小计,孩子变化时让它和所有祖先的缓存失效(节点要记住父节点);或者需要全部小计时自底向上走一遍,每个节点只算一次,10 万个节点就是 10 万次。本章的 describe 其实就有这个问题:礼盒那一行调 price() 时已经走过一遍子树,往下打印两个套装时又各走一遍。三层的小礼盒无所谓,大目录就得改。
模式 意图 区别
组合(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 章的练习。

写一个文件系统模型: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 上。

  • 组合让叶子和容器实现同一个接口,容器持有的孩子类型就是这个接口;调用方对一件单品和一整棵树,写法一样。
  • 走树的递归只写一次,写在容器的方法里;调用方没有类型判断,也就写不出“只递归了一层”的函数。树多一层、加一种节点,不用改已有的类。
  • 代价是每加一种操作,要在接口、叶子、容器上各加一个方法;操作多、节点种类固定时,看第 22 章的迭代器或第 26 章的访问者,它们把遍历搬到树外。
  • 本书用安全式:add、remove 只在容器上,叶子调不到;透明式把它们放进接口,调用方不必分类型,代价是错误推迟到运行时。
  • 和装饰器的分别:装饰器只包一个对象,为了加功能;组合持有一组孩子,为了把一组当一个用。