Python 设计模式 —— 访问者模式(Visitor Pattern)详解
本文面向新手,会从「为什么需要」「是什么」「怎么用」到「多个完整示例」逐步讲解,并附带大量代码。
一、先从一个真实问题说起
1.1 场景:你要处理多种「节点类型」
假设你在写一个抽象语法树(AST)的小工具:程序会被解析成一棵树,树上有多种节点,比如:
- 数字节点(如
42) - 加法节点(如
a + b,有左、右两个子节点) - 乘法节点(如
a * b)
你现在需要为这些节点写多种「操作」:
- 操作 1:把树打印成人类可读的字符串(如
(1 + 2) * 3) - 操作 2:求值,算出这棵树代表的数值
- 操作 3:导出为 JSON,方便别的程序用
一种很直观的写法是:在每个节点类里都实现这 3 个方法,比如:
class NumberNode:
def __init__(self, value):
self.value = value
def print_str(self): # 打印
return str(self.value)
def evaluate(self): # 求值
return self.value
def to_json(self): # 转 JSON
return {"type": "number", "value": self.value}
class AddNode:
def __init__(self, left, right):
self.left = left
self.right = right
def print_str(self):
return f"({self.left.print_str()} + {self.right.print_str()})"
def evaluate(self):
return self.left.evaluate() + self.right.evaluate()
def to_json(self):
return {"type": "add", "left": self.left.to_json(), "right": self.right.to_json()}
问题很快就来了:
- 每增加一种新节点(比如
MulNode),你就要在所有节点类里都加一遍print_str、evaluate、to_json。 - 每增加一种新操作(比如「生成 C 代码」),你就要打开并修改每一个节点类。
也就是说:节点类型和对节点的操作强耦合在一起,扩展任一方都会牵一发动全身。这种写法在类型多、操作多的项目里会很难维护。
1.2 我们更希望的效果
- 加一种新操作(比如「生成 C 代码」):只写一个新的「访问者」类,不用改任何节点类。
- 加一种新节点:只改每个访问者里对应这种节点的处理逻辑,节点类本身保持稳定。
访问者模式要解决的就是:把「对节点的各种操作」从节点类里抽出来,变成独立的「访问者」,让扩展操作和扩展节点都更清晰。
下面我们就正式介绍访问者模式是什么、怎么用。
二、访问者模式是什么?
2.1 一句话定义
访问者模式(Visitor Pattern):把「对一组固定类型元素的多种操作」拆成多个访问者(Visitor),每个访问者负责一种操作;元素只提供「接受访问者」的接口,具体做什么由访问者决定。
这样:
- 元素只关心自己的数据结构(有哪些子节点、属性等)。
- 操作都集中在各自的访问者里,新增操作 = 新增一个访问者类。
2.2 核心角色(一定要搞清)
| 角色 | 含义 | 通俗理解 |
|---|---|---|
| Element(元素) | 能被「访问」的对象 | 树里的每种节点(数字、加法、乘法等) |
| ConcreteElement(具体元素) | 具体的节点类型 | NumberNode、AddNode、MulNode |
| Visitor(访问者) | 定义「能访问哪些元素」的接口 | 声明 visit_NumberNode(e)、visit_AddNode(e) 等方法 |
| ConcreteVisitor(具体访问者) | 某一种具体操作 | 打印访问者、求值访问者、JSON 导出访问者 |
| ObjectStructure(对象结构) | 通常是一颗树或集合 | 整棵 AST,从根开始让访问者遍历 |
关系可以概括为:
- 元素有一个方法:
accept(visitor)→ 内部会调用visitor.visit_XXX(self)。 - 访问者针对每一种具体元素都有一个
visit_XXX(element)方法,在这里写「对这种元素做啥」。
这样,「谁来做」和「对谁做」就分开了:元素只负责「接受访问」,访问者负责「具体逻辑」。
三、访问者模式的结构(用类图理解)
用文字描述一下调用链,便于写代码时对照:
- 你有一个具体访问者,比如
PrintVisitor。 - 你从对象结构的根节点开始,调用
root.accept(print_visitor)。 - 在
root.accept(visitor)里,根节点会调用visitor.visit_AddNode(self)(假设根是加法节点)。 - 在
PrintVisitor.visit_AddNode(self, node)里,你写:先访问左子节点、再访问右子节点,再拼字符串;访问子节点时又是node.left.accept(visitor),形成递归。 - 这样,整棵树就被「用打印这种方式」遍历了一遍,且打印逻辑全在
PrintVisitor里,节点类不用管。
关键点:
- 元素类只有
accept(visitor)和自身数据结构。 - 所有「打印 / 求值 / 导出 JSON」都分别在各自的 Visitor 里实现。
四、第一个完整示例:AST 打印、求值、导出 JSON
下面用「数字、加法、乘法」三种节点,实现三种操作:打印、求值、导出 JSON。你可以直接复制运行。
4.1 定义「元素」抽象 + 访问者抽象
from abc import ABC, abstractmethod
# ---------- 元素(节点)抽象 ----------
class ASTNode(ABC):
"""所有节点的基类:只负责「接受访问者」"""
@abstractmethod
def accept(self, visitor): # 接受任意访问者
pass
# ---------- 访问者抽象 ----------
class ASTVisitor(ABC):
"""访问者:定义「能访问哪些具体节点」"""
def visit_number(self, node): pass
def visit_add(self, node): pass
def visit_mul(self, node): pass
这里用「抽象基类 + 空实现」让子类只重写关心的方法,Python 里很常见。
4.2 具体元素:三种节点
每个节点在 accept 里调用访问者对应的 visit_xxx(self),把「自己」传进去。
class NumberNode(ASTNode):
def __init__(self, value):
self.value = value
def accept(self, visitor):
return visitor.visit_number(self)
class AddNode(ASTNode):
def __init__(self, left, right):
self.left = left # 左子节点
self.right = right # 右子节点
def accept(self, visitor):
return visitor.visit_add(self)
class MulNode(ASTNode):
def __init__(self, left, right):
self.left = left
self.right = right
def accept(self, visitor):
return visitor.visit_mul(self)
4.3 具体访问者 1:打印
打印时,对加法/乘法需要先递归访问子节点,再用括号和符号拼起来。
class PrintVisitor(ASTVisitor):
def visit_number(self, node):
return str(node.value)
def visit_add(self, node):
left_s = node.left.accept(self) # 用「当前访问者」访问左子树
right_s = node.right.accept(self)
return f"({left_s} + {right_s})"
def visit_mul(self, node):
left_s = node.left.accept(self)
right_s = node.right.accept(self)
return f"({left_s} * {right_s})"
4.4 具体访问者 2:求值
class EvalVisitor(ASTVisitor):
def visit_number(self, node):
return node.value
def visit_add(self, node):
return node.left.accept(self) + node.right.accept(self)
def visit_mul(self, node):
return node.left.accept(self) * node.right.accept(self)
4.5 具体访问者 3:导出 JSON
import json
class JSONVisitor(ASTVisitor):
def visit_number(self, node):
return {"type": "number", "value": node.value}
def visit_add(self, node):
return {
"type": "add",
"left": node.left.accept(self),
"right": node.right.accept(self),
}
def visit_mul(self, node):
return {
"type": "mul",
"left": node.left.accept(self),
"right": node.right.accept(self),
}
4.6 使用方式:对象结构就是「根节点」
# 构建 (1 + 2) * 3 的 AST
one = NumberNode(1)
two = NumberNode(2)
three = NumberNode(3)
add = AddNode(one, two)
mul = MulNode(add, three)
root = mul # 根节点就是「对象结构」的入口
# 三种操作 = 三种访问者,节点类不用改
print_visitor = PrintVisitor()
eval_visitor = EvalVisitor()
json_visitor = JSONVisitor()
print(root.accept(print_visitor)) # ((1 + 2) * 3)
print(root.accept(eval_visitor)) # 9
print(json.dumps(root.accept(json_visitor), indent=2))
小结:
- 加一种新操作:再写一个
ASTVisitor子类,实现visit_number/visit_add/visit_mul即可,不用改任何节点。 - 加一种新节点:新增一个节点类(如
SubNode),在基类里加visit_sub,在所有已有访问者里实现visit_sub;节点只负责accept里调用visitor.visit_sub(self)。
五、第二个示例:文件系统(统计大小、列清单)
再举一个和 AST 不同的例子:文件系统。节点只有两种:文件(叶子)和目录(有子节点)。两种操作:统计总大小、列清单。
5.1 元素与访问者抽象
from abc import ABC, abstractmethod
class FileSystemNode(ABC):
@abstractmethod
def accept(self, visitor):
pass
class FSVisitor(ABC):
def visit_file(self, node): pass
def visit_directory(self, node): pass
5.2 具体元素:文件、目录
class File(FileSystemNode):
def __init__(self, name, size):
self.name = name
self.size = size
def accept(self, visitor):
return visitor.visit_file(self)
class Directory(FileSystemNode):
def __init__(self, name):
self.name = name
self.children = []
def add(self, node):
self.children.append(node)
def accept(self, visitor):
return visitor.visit_directory(self)
5.3 访问者:总大小、列清单
class SizeVisitor(FSVisitor):
def visit_file(self, node):
return node.size
def visit_directory(self, node):
total = 0
for child in node.children:
total += child.accept(self)
return total
class ListVisitor(FSVisitor):
def __init__(self):
self.lines = []
def visit_file(self, node):
self.lines.append(f" 文件: {node.name}, 大小: {node.size}")
def visit_directory(self, node):
self.lines.append(f"目录: {node.name}")
for child in node.children:
child.accept(self)
return None
def get_result(self):
return "n".join(self.lines)
5.4 使用
root = Directory("根目录")
root.add(File("a.txt", 100))
root.add(File("b.txt", 200))
sub = Directory("子目录")
sub.add(File("c.txt", 300))
root.add(sub)
print(SizeVisitor().visit_directory(root)) # 600
lv = ListVisitor()
root.accept(lv)
print(lv.get_result())
六、访问者模式的双分派(选读)
为什么一定要写 node.accept(visitor),再在内部调 visitor.visit_xxx(node)?
- 第一次分派:根据
node的实际类型,决定调用哪个类的accept(多态)。 - 第二次分派:在
accept里又根据visitor的类型,调用visit_number/visit_add等(多态)。
这样就能在运行期同时根据「节点类型」和「访问者类型」选对方法,即「双分派」。如果只在节点里写 visitor.visit(self) 而不分节点类型,访问者就难以区分是数字还是加法节点。所以「元素主动调用 visit_xxx(self)」是访问者模式的关键。
七、优缺点与适用场景
7.1 优点
- 新增操作容易:加一个 ConcreteVisitor 即可,符合开闭原则(对扩展开放)。
- 相关逻辑集中:一种操作的所有分支都在一个访问者类里,易读易改。
- 节点类稳定:节点只保留数据 +
accept,不用塞满各种业务方法。
7.2 缺点
- 增加新元素类型代价大:每加一种节点,所有访问者都要加一个
visit_xxx,漏写容易出错。 - 访问者需要知道所有具体元素:依赖具体类型,和「依赖抽象」有冲突,元素类型变化会波及所有访问者。
- 元素必须暴露足够信息:访问者要访问元素的内部结构(如子节点、属性),所以元素不能完全封装。
7.3 何时用、何时不用
适合用访问者的情况:
- 元素类型相对稳定,但操作会不断增加(如编译器里 AST 节点固定,但优化、打印、导出等多种 pass)。
- 多种差异较大的操作作用在同一组类型上,希望每种操作单独成类。
不适合用的情况:
- 元素类型经常新增或删除,你不想改所有访问者。
- 只有一两种简单操作,用几个函数或节点自己的方法就够用。
八、新手常见误区
-
忘记在子节点上继续 accept(visitor)
在visit_add里一定要写node.left.accept(self)和node.right.accept(self),否则只处理了当前节点,树没有遍历下去。 -
访问者里直接依赖具体节点类型
访问者本来就是为「每种具体节点」写逻辑的,所以visit_add(self, node)里用node.left、node.right是正常的;但要避免在访问者里再写if type(node) == ...,应依赖多态(不同 visit_xxx)来区分。 -
混淆「遍历」和「访问」
遍历(谁先谁后、走哪些节点)通常由访问者在visit_xxx里通过child.accept(self)的调用顺序决定;访问者模式负责的是「每到一个节点做什么」,不负责数据结构本身的存储方式。 -
在元素里写业务逻辑
一旦用了访问者模式,就尽量把「打印、求值、导出」等都放到访问者里,元素只做accept和保存数据,否则模式就半吊子,维护时容易乱。
九、小结
- 访问者模式把「对一组元素的多种操作」拆成多个访问者类,每个访问者实现一种操作。
- 元素只提供
accept(visitor),内部调用visitor.visit_具体类型(self),实现双分派。 - 扩展新操作:新增一个 ConcreteVisitor;扩展新元素:在所有访问者里新增对应的
visit_xxx。 - 适合元素类型较稳定、操作多样且会不断增加的场景(如 AST、文档树、复杂配置树等)。
建议先跑通上面的 AST 示例,再试着自己加一个「生成 C 代码」的访问者,或给文件系统加一个「按扩展名统计」的访问者,体会「加操作不加节点类」的用法。遇到问题可以再结合本文的「常见误区」和「优缺点」对照检查。