python设计模式–访问者模式(Visitor Pattern)

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_strevaluateto_json
  • 增加一种新操作(比如「生成 C 代码」),你就要打开并修改每一个节点类

也就是说:节点类型对节点的操作强耦合在一起,扩展任一方都会牵一发动全身。这种写法在类型多、操作多的项目里会很难维护。


1.2 我们更希望的效果

  • 加一种新操作(比如「生成 C 代码」):只写一个新的「访问者」类,不用改任何节点类。
  • 加一种新节点:只改每个访问者里对应这种节点的处理逻辑,节点类本身保持稳定。

访问者模式要解决的就是:把「对节点的各种操作」从节点类里抽出来,变成独立的「访问者」,让扩展操作和扩展节点都更清晰。

下面我们就正式介绍访问者模式是什么、怎么用。


二、访问者模式是什么?

2.1 一句话定义

访问者模式(Visitor Pattern):把「对一组固定类型元素的多种操作」拆成多个访问者(Visitor),每个访问者负责一种操作;元素只提供「接受访问者」的接口,具体做什么由访问者决定。

这样:

  • 元素只关心自己的数据结构(有哪些子节点、属性等)。
  • 操作都集中在各自的访问者里,新增操作 = 新增一个访问者类。

2.2 核心角色(一定要搞清)

角色 含义 通俗理解
Element(元素) 能被「访问」的对象 树里的每种节点(数字、加法、乘法等)
ConcreteElement(具体元素) 具体的节点类型 NumberNodeAddNodeMulNode
Visitor(访问者) 定义「能访问哪些元素」的接口 声明 visit_NumberNode(e)visit_AddNode(e) 等方法
ConcreteVisitor(具体访问者) 某一种具体操作 打印访问者、求值访问者、JSON 导出访问者
ObjectStructure(对象结构) 通常是一颗树或集合 整棵 AST,从根开始让访问者遍历

关系可以概括为:

  • 元素有一个方法:accept(visitor) → 内部会调用 visitor.visit_XXX(self)
  • 访问者针对每一种具体元素都有一个 visit_XXX(element) 方法,在这里写「对这种元素做啥」。

这样,「谁来做」和「对谁做」就分开了:元素只负责「接受访问」,访问者负责「具体逻辑」。


三、访问者模式的结构(用类图理解)

用文字描述一下调用链,便于写代码时对照:

  1. 你有一个具体访问者,比如 PrintVisitor
  2. 你从对象结构的根节点开始,调用 root.accept(print_visitor)
  3. root.accept(visitor) 里,根节点会调用 visitor.visit_AddNode(self)(假设根是加法节点)。
  4. PrintVisitor.visit_AddNode(self, node) 里,你写:先访问左子节点、再访问右子节点,再拼字符串;访问子节点时又是 node.left.accept(visitor),形成递归。
  5. 这样,整棵树就被「用打印这种方式」遍历了一遍,且打印逻辑全在 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)。
  • 多种差异较大的操作作用在同一组类型上,希望每种操作单独成类。

不适合用的情况:

  • 元素类型经常新增或删除,你不想改所有访问者。
  • 只有一两种简单操作,用几个函数或节点自己的方法就够用。

八、新手常见误区

  1. 忘记在子节点上继续 accept(visitor)
    visit_add 里一定要写 node.left.accept(self)node.right.accept(self),否则只处理了当前节点,树没有遍历下去。

  2. 访问者里直接依赖具体节点类型
    访问者本来就是为「每种具体节点」写逻辑的,所以 visit_add(self, node) 里用 node.leftnode.right 是正常的;但要避免在访问者里再写 if type(node) == ...,应依赖多态(不同 visit_xxx)来区分。

  3. 混淆「遍历」和「访问」
    遍历(谁先谁后、走哪些节点)通常由访问者在 visit_xxx 里通过 child.accept(self) 的调用顺序决定;访问者模式负责的是「每到一个节点做什么」,不负责数据结构本身的存储方式。

  4. 在元素里写业务逻辑
    一旦用了访问者模式,就尽量把「打印、求值、导出」等都放到访问者里,元素只做 accept 和保存数据,否则模式就半吊子,维护时容易乱。


九、小结

  • 访问者模式把「对一组元素的多种操作」拆成多个访问者类,每个访问者实现一种操作。
  • 元素只提供 accept(visitor),内部调用 visitor.visit_具体类型(self),实现双分派。
  • 扩展新操作:新增一个 ConcreteVisitor;扩展新元素:在所有访问者里新增对应的 visit_xxx
  • 适合元素类型较稳定、操作多样且会不断增加的场景(如 AST、文档树、复杂配置树等)。

建议先跑通上面的 AST 示例,再试着自己加一个「生成 C 代码」的访问者,或给文件系统加一个「按扩展名统计」的访问者,体会「加操作不加节点类」的用法。遇到问题可以再结合本文的「常见误区」和「优缺点」对照检查。

发表评论