跳转到内容

第 26 章 · 访问者(Visitor)

一句话:节点类只留一个 accept(visitor),每种新操作写成一个访问者类,里面给每种节点各写一个方法。 解决的痛:每加一张报表,Item、Product、Bundle 各加一个方法;漏写一个类不报错,套餐里的缺货商品就没被报出来。

还是第 13 章的商品树:单品 Product,套餐 Bundle,两者都是 Item,套餐里能再放套餐,年货礼盒就是“坚果套装 + 茶叶套装 + 贺卡”再打九折。这次单品多了两个字段:税率和库存(本章用的是示例税率)。

财务要几张报表:按税率算出整单的税额;把整棵树导成扁平的 CSV,每个单品一行,带上它在树里的路径;检查哪些商品缺货。

第 13 章的做法是“操作写成节点上的方法”,那就照着来:每张报表在 Item 上声明一个方法,Product、Bundle 各实现一遍。先做了前两张:

from abc import ABC, abstractmethod
from dataclasses import dataclass
class Item(ABC): # 组件,这次写成抽象类:每张报表在这里加一个方法
@abstractmethod
def tax_fen(self) -> int: ... # 报表一:税额,单位分
@abstractmethod
def csv_rows(self, prefix: str = "") -> list[str]: ... # 报表二:扁平 CSV,每个单品一行
@dataclass
class Product(Item): # 单品
name: str
price: int # 元
tax_rate: int # 税率,9 就是 9%
stock: int # 库存
def tax_fen(self) -> int:
return self.price * self.tax_rate # 元 × 百分数,正好是分
def csv_rows(self, prefix: str = "") -> list[str]:
return [f"{prefix}{self.name},{self.price},{self.tax_rate}"]
@dataclass
class Bundle(Item): # 套餐:手里是一组 Item
name: str
items: list[Item]
percent: int = 100 # 整体折扣,90 就是九折
def tax_fen(self) -> int: # 孩子的税加起来,再按折扣打折(示例数字都能整除)
return sum(i.tax_fen() for i in self.items) * self.percent // 100
def csv_rows(self, prefix: str = "") -> list[str]: # 路径往下传,每层接上自己的名字
return [row for i in self.items for row in i.csv_rows(f"{prefix}{self.name}/")]
def yuan(fen: int) -> str:
return f"{fen // 100}.{fen % 100:02d}"
def new_year_box() -> Bundle: # 第 13 章的年货礼盒,单品多了税率和库存
nuts = Bundle("坚果套装", [Product("碧根果", 68, 9, 30), Product("夏威夷果", 58, 9, 0),
Product("巴旦木", 44, 9, 12)])
tea = Bundle("茶叶套装", [Product("龙井", 128, 9, 8), Product("普洱", 92, 9, 5)])
return Bundle("年货礼盒", [nuts, tea, Product("贺卡", 10, 13, 200)], percent=90)
if __name__ == "__main__":
cart: list[Item] = [new_year_box(), Product("保温杯", 99, 13, 0)]
print(f"税额:{yuan(sum(i.tax_fen() for i in cart))} 元")
print("商品,单价,税率")
print("\n".join(row for i in cart for row in i.csv_rows()))
# 输出:
# 税额:45.63 元
# 商品,单价,税率
# 年货礼盒/坚果套装/碧根果,68,9
# 年货礼盒/坚果套装/夏威夷果,58,9
# 年货礼盒/坚果套装/巴旦木,44,9
# 年货礼盒/茶叶套装/龙井,128,9
# 年货礼盒/茶叶套装/普洱,92,9
# 年货礼盒/贺卡,10,13
# 保温杯,99,13

TS 版一样,Item 是抽象类:

abstract class Item { // 组件,这次写成抽象类:每张报表在这里加一个方法
abstract taxFen(): number; // 报表一:税额,单位分
abstract csvRows(prefix?: string): string[]; // 报表二:扁平 CSV,每个单品一行
}
class Product extends Item { // 单品
constructor(
readonly name: string,
readonly price: number, // 元
readonly taxRate: number, // 税率,9 就是 9%
readonly stock: number, // 库存
) {
super();
}
taxFen(): number {
return this.price * this.taxRate; // 元 × 百分数,正好是分
}
csvRows(prefix = ""): string[] {
return [`${prefix}${this.name},${this.price},${this.taxRate}`];
}
}
class Bundle extends Item { // 套餐:手里是一组 Item
constructor(readonly name: string, readonly items: Item[], readonly percent = 100) {
super();
}
taxFen(): number { // 孩子的税加起来,再按折扣打折(示例数字都能整除)
const sum = this.items.reduce((s, i) => s + i.taxFen(), 0);
return Math.floor((sum * this.percent) / 100);
}
csvRows(prefix = ""): string[] { // 路径往下传,每层接上自己的名字
return this.items.flatMap((i) => i.csvRows(`${prefix}${this.name}/`));
}
}
function yuan(fen: number): string {
return `${Math.floor(fen / 100)}.${String(fen % 100).padStart(2, "0")}`;
}
function newYearBox(): Bundle { // 第 13 章的年货礼盒,单品多了税率和库存
const nuts = new Bundle("坚果套装", [
new Product("碧根果", 68, 9, 30),
new Product("夏威夷果", 58, 9, 0),
new Product("巴旦木", 44, 9, 12),
]);
const tea = new Bundle("茶叶套装", [new Product("龙井", 128, 9, 8), new Product("普洱", 92, 9, 5)]);
return new Bundle("年货礼盒", [nuts, tea, new Product("贺卡", 10, 13, 200)], 90);
}
const cart: Item[] = [newYearBox(), new Product("保温杯", 99, 13, 0)];
console.log(`税额:${yuan(cart.reduce((s, i) => s + i.taxFen(), 0))} 元`);
console.log("商品,单价,税率");
console.log(cart.flatMap((i) => i.csvRows()).join("\n"));
// 输出:
// 税额:45.63 元
// 商品,单价,税率
// 年货礼盒/坚果套装/碧根果,68,9
// 年货礼盒/坚果套装/夏威夷果,58,9
// 年货礼盒/坚果套装/巴旦木,44,9
// 年货礼盒/茶叶套装/龙井,128,9
// 年货礼盒/茶叶套装/普洱,92,9
// 年货礼盒/贺卡,10,13
// 保温杯,99,13

税额以分为单位:坚果和茶叶五样按 9% 共 3510 分,贺卡按 13% 是 130 分,礼盒 3640 分,九折后 3276 分;保温杯按 13% 是 1287 分;合计 4563 分。两张报表各是一对方法,单品给出自己的数,套餐汇总孩子的,和第 13 章的 price()、weight() 一个写法。和第 13 章不同的是,Item 从 Protocol 换成了抽象类:Product、Bundle 继承它,几个类共用的默认实现可以写在 Item 上。

财务又要:加上缺货检查;之后还有两张:按税率汇总、按供应商对账。

每张新报表都要改同样的三处:

  1. Item:声明新方法。
  2. Product:实现它。
  3. Bundle:也实现它,把孩子的结果汇总。

新同事做缺货检查。他在 Item 上写了个默认实现,免得哪个子类没写就跑不起来;Product 改了,Bundle 忘了:

class Item(ABC):
...
def out_of_stock(self) -> list[str]: # ① 报表三:缺货检查;给个默认值,没写的子类也能跑
return []
@dataclass
class Product(Item):
...
def out_of_stock(self) -> list[str]: # ② 库存为 0 就报出来
return [self.name] if self.stock == 0 else []
# ③ Bundle:漏了,继承了 Item 的 return []

入口加一行:

print("缺货:" + "、".join(name for i in cart for name in i.out_of_stock()))

前面九行不变,最后多出一行:

缺货:保温杯

夏威夷果库存是 0,它在坚果套装里,坚果套装在礼盒里。礼盒调的是继承来的 out_of_stock,直接返回空列表,根本没往下走。程序不报错,类型检查也过,因为 Bundle 确实“有”这个方法。仓库照这张单子补货,夏威夷果没补,礼盒照卖。TS 版要改的是同样三处,Item 上写一个非抽象的 outOfStock(),结果一样。

问题的形状是:每加一张报表,三个节点类各加一个方法,报表逻辑分散在三个类里;漏一个类,那个类就悄悄用默认值。把 out_of_stock 也标成 @abstractmethod 就能挡住这次事故,但一张报表仍分在三个类里。再加两张,Product、Bundle 各有五个报表方法,和商品本身没关系。

直觉写法:每张报表在三个类上各加一个方法,Bundle 漏了缺货检查

浅红的 ①② 是改了的两个类,加粗的 ③ Bundle 就是漏掉的那一处,虚线指向事故。

把报表从节点里搬出去。节点只留一个方法 accept(visitor),单品在里面调 visitor.visit_product(self),套餐调 visitor.visit_bundle(self)。每张报表写成一个访问者(visitor)类,两种节点各有一个 visit_ 方法:

from abc import ABC, abstractmethod
from dataclasses import dataclass
class Visitor[T](ABC): # 一张报表一个访问者:节点有几种,就有几个 visit 方法
@abstractmethod
def visit_product(self, p: "Product") -> T: ...
@abstractmethod
def visit_bundle(self, b: "Bundle") -> T: ...
class Item(ABC): # 节点只认一个方法,以后加报表不再改它
@abstractmethod
def accept[T](self, v: Visitor[T]) -> T: ...
@dataclass
class Product(Item):
name: str
price: int # 元
tax_rate: int # 税率,9 就是 9%
stock: int # 库存
def accept[T](self, v: Visitor[T]) -> T:
return v.visit_product(self) # 我是单品,就调 visit_product
@dataclass
class Bundle(Item):
name: str
items: list[Item]
percent: int = 100 # 整体折扣,90 就是九折
def accept[T](self, v: Visitor[T]) -> T:
return v.visit_bundle(self) # 我是套餐,就调 visit_bundle
class TaxReport(Visitor[int]): # 报表一:税额,单位分
def visit_product(self, p: Product) -> int:
return p.price * p.tax_rate
def visit_bundle(self, b: Bundle) -> int:
return sum(i.accept(self) for i in b.items) * b.percent // 100
class CsvExport(Visitor[list[str]]): # 报表二:扁平 CSV;路径记在访问者自己身上
def __init__(self) -> None:
self._path: list[str] = []
def visit_product(self, p: Product) -> list[str]:
return ["/".join([*self._path, p.name]) + f",{p.price},{p.tax_rate}"]
def visit_bundle(self, b: Bundle) -> list[str]:
self._path.append(b.name)
rows = [row for i in b.items for row in i.accept(self)]
self._path.pop()
return rows
class StockCheck(Visitor[list[str]]): # 报表三:新加的就是这一个类
def visit_product(self, p: Product) -> list[str]:
return [p.name] if p.stock == 0 else []
def visit_bundle(self, b: Bundle) -> list[str]: # 不写这个方法,类就建不出来
return [name for i in b.items for name in i.accept(self)]
def yuan(fen: int) -> str:
return f"{fen // 100}.{fen % 100:02d}"
def new_year_box() -> Bundle:
nuts = Bundle("坚果套装", [Product("碧根果", 68, 9, 30), Product("夏威夷果", 58, 9, 0),
Product("巴旦木", 44, 9, 12)])
tea = Bundle("茶叶套装", [Product("龙井", 128, 9, 8), Product("普洱", 92, 9, 5)])
return Bundle("年货礼盒", [nuts, tea, Product("贺卡", 10, 13, 200)], percent=90)
if __name__ == "__main__":
cart: list[Item] = [new_year_box(), Product("保温杯", 99, 13, 0)]
tax = TaxReport()
print(f"税额:{yuan(sum(i.accept(tax) for i in cart))} 元")
csv = CsvExport()
print("商品,单价,税率")
print("\n".join(row for i in cart for row in i.accept(csv)))
stock = StockCheck()
print("缺货:" + "、".join(name for i in cart for name in i.accept(stock)))
# 输出:
# 税额:45.63 元
# 商品,单价,税率
# 年货礼盒/坚果套装/碧根果,68,9
# 年货礼盒/坚果套装/夏威夷果,58,9
# 年货礼盒/坚果套装/巴旦木,44,9
# 年货礼盒/茶叶套装/龙井,128,9
# 年货礼盒/茶叶套装/普洱,92,9
# 年货礼盒/贺卡,10,13
# 保温杯,99,13
# 缺货:夏威夷果、保温杯

TS 版的 Visitor 是泛型接口:

interface Visitor<T> { // 一张报表一个访问者:节点有几种,就有几个 visit 方法
visitProduct(p: Product): T;
visitBundle(b: Bundle): T;
}
interface Item { // 节点只认一个方法,以后加报表不再改它
accept<T>(v: Visitor<T>): T;
}
class Product implements Item {
constructor(
readonly name: string,
readonly price: number, // 元
readonly taxRate: number, // 税率,9 就是 9%
readonly stock: number, // 库存
) {}
accept<T>(v: Visitor<T>): T {
return v.visitProduct(this); // 我是单品,就调 visitProduct
}
}
class Bundle implements Item {
constructor(readonly name: string, readonly items: Item[], readonly percent = 100) {}
accept<T>(v: Visitor<T>): T {
return v.visitBundle(this); // 我是套餐,就调 visitBundle
}
}
class TaxReport implements Visitor<number> { // 报表一:税额,单位分
visitProduct(p: Product): number {
return p.price * p.taxRate;
}
visitBundle(b: Bundle): number {
const sum = b.items.reduce((s, i) => s + i.accept(this), 0);
return Math.floor((sum * b.percent) / 100);
}
}
class CsvExport implements Visitor<string[]> { // 报表二:扁平 CSV;路径记在访问者自己身上
private readonly path: string[] = [];
visitProduct(p: Product): string[] {
return [[...this.path, p.name].join("/") + `,${p.price},${p.taxRate}`];
}
visitBundle(b: Bundle): string[] {
this.path.push(b.name);
const rows = b.items.flatMap((i) => i.accept(this));
this.path.pop();
return rows;
}
}
class StockCheck implements Visitor<string[]> { // 报表三:新加的就是这一个类
visitProduct(p: Product): string[] {
return p.stock === 0 ? [p.name] : [];
}
visitBundle(b: Bundle): string[] { // 不写这个方法,编译不过
return b.items.flatMap((i) => i.accept(this));
}
}
function yuan(fen: number): string {
return `${Math.floor(fen / 100)}.${String(fen % 100).padStart(2, "0")}`;
}
function newYearBox(): Bundle {
const nuts = new Bundle("坚果套装", [
new Product("碧根果", 68, 9, 30),
new Product("夏威夷果", 58, 9, 0),
new Product("巴旦木", 44, 9, 12),
]);
const tea = new Bundle("茶叶套装", [new Product("龙井", 128, 9, 8), new Product("普洱", 92, 9, 5)]);
return new Bundle("年货礼盒", [nuts, tea, new Product("贺卡", 10, 13, 200)], 90);
}
const cart: Item[] = [newYearBox(), new Product("保温杯", 99, 13, 0)];
const tax = new TaxReport();
console.log(`税额:${yuan(cart.reduce((s, i) => s + i.accept(tax), 0))} 元`);
const csv = new CsvExport();
console.log("商品,单价,税率");
console.log(cart.flatMap((i) => i.accept(csv)).join("\n"));
const stock = new StockCheck();
console.log(`缺货:${cart.flatMap((i) => i.accept(stock)).join("、")}`);
// 输出:
// 税额:45.63 元
// 商品,单价,税率
// 年货礼盒/坚果套装/碧根果,68,9
// 年货礼盒/坚果套装/夏威夷果,58,9
// 年货礼盒/坚果套装/巴旦木,44,9
// 年货礼盒/茶叶套装/龙井,128,9
// 年货礼盒/茶叶套装/普洱,92,9
// 年货礼盒/贺卡,10,13
// 保温杯,99,13
// 缺货:夏威夷果、保温杯

访问者版:节点只有 accept,每张报表一个访问者类

黄色的 StockCheck 是这次新加的唯一一个类;Item、Product、Bundle 上只有 accept。

一次缺货检查:礼盒 accept,访问者 visit_bundle,再让每个孩子 accept

每一步都是“节点调访问者的 visit_ 方法,访问者再让孩子 accept”,一路下到夏威夷果。

对照 26.3:

  • 第 ① 处:Item 不动,它只有 accept。
  • 第 ②③ 处:Product、Bundle 不动。缺货检查的两种情况都写在 StockCheck 一个类里,看一眼就知道套餐怎么处理。
  • 事故:缺货检查对单品、对套餐的做法并排写在 StockCheck 一个类里,少了 visit_bundle 一眼就看得出。visit_bundle 又是抽象方法,少写时 Python 在 StockCheck() 时报 TypeError: Can't instantiate abstract class,TS 编译不过;在访问者里标成抽象不费事。反过来,访问者基类要是给 visit_bundle 写了默认实现(像 ast.NodeVisitor 的 generic_visit),漏写又会悄悄走默认值。后两张报表也各是一个类。

代价正好反过来。以后要加一种节点,比如“赠品”Gift(不卖钱、有库存),Visitor 加 visit_gift,TaxReport、CsvExport、StockCheck 和后加的两个访问者全都要改,连 Visitor 一共六处。第 13 章 13.4 说组合“加节点便宜、加操作贵”;访问者是“加操作便宜、加节点贵”。哪边变得多,就选哪一边:这里商品只有单品和套餐两种,报表却一张张地来,所以选访问者。

GoF 对访问者的定义是:表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下定义作用于这些元素的新操作。

关键是双分派(double dispatch)。item.accept(v) 先按 item 的实际类型选出 Product.accept 或 Bundle.accept,这是第一次;它里面写死调 visit_product 还是 visit_bundle,再按 v 的实际类型选出哪张报表的方法,这是第二次。两次加起来,最终执行哪段代码由“节点类型 × 报表类型”共同决定,调用方一个 isinstance 都不用写。

访问者的结构:Element 只有 accept,ConcreteVisitor 为每种 ConcreteElement 写一个 visit 方法

对照案例图:角色图的 Visitor 就是本章的 Visitor 类,visitConcreteElementA/B 对应 visit_product、visit_bundle;三张报表是 ConcreteVisitor;Item 是 Element。

角色 在本章案例里是谁 职责
访问者(Visitor) Visitor 给每种具体元素声明一个 visit 方法
具体访问者(ConcreteVisitor) TaxReport、CsvExport、StockCheck 一种操作,对每种元素各写一种做法
元素(Element) Item 声明 accept(visitor)
具体元素(ConcreteElement) Product、Bundle 在 accept 里调访问者上属于自己的那个方法
对象结构(ObjectStructure) 购物车 cart 和年货礼盒这棵树 装着元素,让访问者一个个走到

常见场景:

  • 编译器和代码分析:抽象语法树(abstract syntax tree,AST)的节点种类基本固定,检查、优化、生成代码一遍遍地加。
  • 文档导出:同一棵文档树导成 HTML、Markdown、PDF。
  • 报表:本章这样,商品或组织结构不常变,统计口径常变。

标准库 ast 把 Python 源码解析成语法树,ast.NodeVisitor 是现成的访问者基类。它的 visit(node) 按节点类名找方法:Call 节点调 visit_Call,Name 节点调 visit_Name,没写的就调 generic_visit,后者接着访问所有子节点。下面是真实的标准库调用,统计一段源码里每个函数被调了几次:

import ast
SOURCE = """
def checkout(cart):
total = sum(price(i) for i in cart)
log(len(cart), total)
return round(total * 0.9)
"""
class CallCounter(ast.NodeVisitor): # 真实的标准库基类
def __init__(self) -> None:
self.calls: dict[str, int] = {}
def visit_Call(self, node: ast.Call) -> None: # visit() 看到 Call 节点就分派到这里
name = ast.unparse(node.func)
self.calls[name] = self.calls.get(name, 0) + 1
self.generic_visit(node) # 接着走子节点:参数里还可能有调用
class ShallowCounter(CallCounter):
def visit_Call(self, node: ast.Call) -> None: # 忘了 generic_visit
name = ast.unparse(node.func)
self.calls[name] = self.calls.get(name, 0) + 1
tree = ast.parse(SOURCE)
for counter in (CallCounter(), ShallowCounter()):
counter.visit(tree) # 没写 visit_Module、visit_FunctionDef,都交给 generic_visit 往下走
print(f"{type(counter).__name__}:{counter.calls}")
# 输出:
# CallCounter:{'sum': 1, 'price': 1, 'log': 1, 'len': 1, 'round': 1}
# ShallowCounter:{'sum': 1, 'log': 1, 'round': 1}

CallCounter 是具体访问者,语法树的各种节点类是具体元素。区别是 ast 不靠每个节点类写 accept,而是 visit() 用 "visit_" + 类名 查方法,等于把第一次分派换成了按名字查找。第二行输出说明,visit_Call 里不调 generic_visit,嵌套在参数里的 price、len 就数漏了:往不往下走由访问者决定,这和本章 visit_bundle 自己遍历孩子是一回事。

例二:Babel 插件的 visitor(TypeScript)

Section titled “例二:Babel 插件的 visitor(TypeScript)”

Babel 是把新版 JavaScript 转成旧版的编译器,插件都写成一个 visitor 对象:{ Identifier(path) {…} },键是节点类型。Babel 遍历语法树,走到哪种节点就调哪个方法(大致如此),插件可以读、改 path.node。下面用一个手写的小语法树和遍历器模拟:

// 模拟 Babel 插件:visitor 对象的键是节点类型,遍历器走到这种节点就调对应的方法。
// 简化:真实 Babel 传进来的 path 还有 parent、scope、replaceWith 等,方法也能写成 { enter, exit };
// 子节点按每种类型登记的字段去走,这里用一个 if 代替(大致如此)。
type AstNode =
| { type: "CallExpression"; callee: AstNode; arguments: AstNode[] }
| { type: "Identifier"; name: string }
| { type: "NumericLiteral"; value: number };
type Path<N> = { node: N };
type PluginVisitor = { [K in AstNode["type"]]?: (path: Path<Extract<AstNode, { type: K }>>) => void };
function traverse(node: AstNode, visitor: PluginVisitor): void {
const method = visitor[node.type] as ((path: Path<AstNode>) => void) | undefined;
method?.({ node }); // 按 node.type 找方法:这一步就是分派
if (node.type === "CallExpression") {
traverse(node.callee, visitor);
node.arguments.forEach((arg) => traverse(arg, visitor));
}
}
function generate(node: AstNode): string { // 把语法树打印回源码
if (node.type === "Identifier") return node.name;
if (node.type === "NumericLiteral") return String(node.value);
return `${generate(node.callee)}(${node.arguments.map(generate).join(", ")})`;
}
const id = (name: string): AstNode => ({ type: "Identifier", name });
const num = (value: number): AstNode => ({ type: "NumericLiteral", value });
const ast: AstNode = { // log(price(68), 9)
type: "CallExpression", callee: id("log"),
arguments: [{ type: "CallExpression", callee: id("price"), arguments: [num(68)] }, num(9)],
};
const numbers: number[] = [];
const plugin: { visitor: PluginVisitor } = { // 真实插件是 () => ({ visitor: { ... } })
visitor: {
Identifier(path) {
if (path.node.name === "log") path.node.name = "report"; // 改名
},
NumericLiteral(path) {
numbers.push(path.node.value);
},
},
};
console.log(`转换前:${generate(ast)}`);
traverse(ast, plugin.visitor);
console.log(`转换后:${generate(ast)}`);
console.log(`数字字面量:${numbers.join(", ")}`);
// 输出:
// 转换前:log(price(68), 9)
// 转换后:report(price(68), 9)
// 数字字面量:68, 9

visitor 对象就是具体访问者,Identifier、NumericLiteral 两个方法对应本章的 visit_ 方法。和本章不同的是,遍历写在 traverse 里,插件只管“走到这种节点做什么”;节点也没有 accept,分派靠 visitor[node.type] 这一次查表。

  • 节点类型常常增加。每加一种节点,所有访问者都要改;访问者有十个,就是十处。这时用第 13 章的写法,加节点只加一个类。
  • 只有一两种操作。只有一张报表,Visitor、accept、一个访问者类,比直接在节点上写一个方法多出两层,读的人要在 accept 和 visit_ 之间跳来跳去。
  • 语言有模式匹配(pattern matching)。Python 3.10 起有 match,TS 有可辨识联合(discriminated union)加 switch,一张报表写成一个函数,按类型分支即可:case Product(stock=0, name=n) 一行就能拆出字段。加分支漏一个,TS 也能在编译时发现:在 default 里把节点赋给一个 never 类型的变量,有类型没处理到就报错。第 29 章会专门讲语言特性替掉了哪些模式。
模式 意图 区别
访问者(Visitor) 不改节点类,给一组节点类加新操作 对不同类型的节点做不同的事,由节点 accept 回调访问者里对应的方法
迭代器(Iterator) 不暴露内部结构地逐个访问元素 只管一个个交出元素,对每个元素做什么写在循环里,不分类型。见第 22 章
组合(Composite) 把对象组织成树,单个和整体一样对待 定义树的形状,操作写在节点上:加节点便宜、加操作贵。访问者正好相反。见第 13 章
  • 访问者还是迭代器:问“每个节点做的事,要不要按类型分开写”。数一数有几个单品,迭代器拿出来数就行;单品算税、套餐打折,就是访问者。
  • 访问者还是组合:问“以后加得多的是节点还是操作”。两者常一起用:本章的树仍是组合,只是操作从节点上搬进了访问者。

写一棵表达式树:数字 Num、加法 Add、乘法 Mul,Add 和 Mul 各有左右两个子表达式。写两个访问者:Evaluate 求值,Show 打印成每个运算都带括号的字符串。用 (1 + 2) * 3 + 4 和 2 * (3 + 4) 验证,打印两者的字符串和值。Python 和 TS 各写一版。

提示:Add、Mul 的 accept 只调访问者,不自己算;Evaluate.visit_add 里先让左右两边 accept(self),再相加。

  • 访问者让节点只留一个 accept,每种新操作写成一个访问者类,按节点类型各写一个方法。
  • 加报表只加一个类,节点类不动;访问者少写一个方法,抽象方法和接口会当场报错。
  • 双分派:先按节点类型选 accept,再按访问者类型选 visit_ 方法。
  • 代价和第 13 章相反:加一种节点要改所有访问者。节点种类稳定、操作不断加时才用;有模式匹配的语言里,一个带 match 或 switch 的函数常常更简单。