深入理解访问者模式:双分派的艺术
背景
在 AI Coding 时代,代码实现已经不是瓶颈,更难的反而是你如何去约束 Coding Agent 的行为,让它可以有高质量的代码产出。这些约束一般是比较 high-level 的东西,像是设计模式、软件架构等。在这样的时代背景下,了解这些知识在我看来是更加重要的。以设计模式为例,你需要知道对应的设计模式是什么,它解决了什么问题,适用于什么场景等才能够用清晰的自然语言描述去指导 Coding Agent。今天要要介绍的就是其中一个设计模式————访问者模式(Visitor Pattern)
理解分派(Dispatch)
在深入了解访问者模式之前,我们有必要对面向对象的编程语言(下称 OOP)的分派(Dispatch 机制)有一定了解。因为访问者模式其实是帮助你更好地做双分派(Double Dispatch)。分派的意思就是:当你调用一个方法的时候,决定具体被调用的方法
在面向对象的世界里,方法调用一般遵循如下的风格(以 Python 为例)
obj.method()
在上面的代码中,一般我们称 obj 为 Receiver,method 是具体的方法调用。常见的是单分派(Single Dispatch),即只根据运行时 Receiver 的动态类型决定具体的方法。以此类推,双分派的意思就是根据两个对象运行时的动态类型决定调用的方法。大多数情况下,单分派已经可以满足编程需求。只有在一些很罕见的情况你会想要使用双分派。这可能也是为什么主流编程语言里面很少有支持双分派乃至多分派(Julia 是一个例外)
为了让你更好理解什么是双分派,我这里举一个经典的例子:基于抽象语法树(AST)的分析器。如果用 OOP 的方式建模,那么你会有 2 个基类
- AST 节点的基类:所有 AST 节点的类都直接或间接继承这个基类
- 分析器的基类:所有分析器的类都直接或间接继承这个基类
为什么说这个场景需要双分派?因为你经常需要根据节点类型、分析器类型来决定调用哪个函数,比如
- 你有一个
PrettyPrinter分析器,用于打印 AST- 遇到
Number节点类型你想要直接打印数字 - 遇到
Add节点类型你想要先递归打印左孩子,然后打印+,然后递归打印右孩子 - …
- 遇到
- 你有一个
Interpreter分析器,用于解释执行 AST 对应的代码- 遇到
Number节点你会返回对应的数字 - 遇到
Add节点类型你会先计算左孩子对应的代码,然后计算右孩子对应的代码,然后把他们加起来 - …
- 遇到
可以看到每一个可能的 (AST 节点、分析器) 组合都有不同的逻辑,这意味着我们需要为每一种可能的组合定义一个方法,并且需要在调用的时候找到对应的方法 :(
访问者模式
当你的编程语言只支持单分派而你又恰好遇到需要双分派的时候,你就可以考虑使用访问者模式了。访问者模式通过将双分派变成等价的 2 次单分派,实现了一样的效果。单分派我们再熟悉不过了,不就是 obj.method 吗?但怎么通过 2 次 obj.method 实现双分派却不容易想到。这里面涉及到 2 个类
- 被访问者对应的类,对面本文例子的“AST 节点”
- 访问者对应的类,对应本文例子的“分析器”
为了方便理解访问者模式,我们这里还是用“基于抽象语法树(AST)的分析器”作为例子,并且以整数的四则运算为例,那么会涉及到如下的类
Expr- AST 节点的基类Number类:表示整数Add:表示加法运算Sub:表示减法运算Mul:表示乘法运算Div:表示除法运算
Visitor类 - 通用的分析器基类
为了文章内容的简洁,我们这里先不展开有哪些分析器,而是后面逐渐增加分析器来查看代码会如何变化
以上面的为例,访问者模式的类图是这样子的
classDiagram
direction TB
class Expr {
+accept(v: Visitor)*
}
class Number {
+value: int
}
class Add {
+lhs: Expr
+rhs: Expr
}
class Sub {
+lhs: Expr
+rhs: Expr
}
class Mul {
+lhs: Expr
+rhs: Expr
}
class Div {
+lhs: Expr
+rhs: Expr
}
class Visitor {
+visitNumber(Number)*
+visitAdd(Add)*
+visitSub(Sub)*
+visitMul(Mul)*
+visitDiv(Div)*
}
Expr <|-- Number
Expr <|-- Add
Expr <|-- Sub
Expr <|-- Mul
Expr <|-- Div
Expr ..> Visitor : accept()
通过观察上面的类图,我们可以发现几件事情
- 在
Expr基类里面我们需要定义accept(v Visitor)抽象方法,它的入参是Visitor - 对于
Visitor基类来说,需要为每一个可能被访问的对象定义visit_*的抽象方法
但上面的类图类图泄露了一个关键的细节:Expr 的子类要如何实现 accept 方法?
答案就在下面的代码里
@dataclass
class Expr(ABC):
@abstractmethod
def accept(self, visitor: Visitor):
raise NotImplementedError
@dataclass
class Number(Expr):
val: int
def accept(self, visitor: Visitor):
return visitor.visit_number(self)
@dataclass
class Add(Expr):
lhs: Expr
rhs: Expr
def accept(self, visitor: Visitor):
return visitor.visit_add(self)
@dataclass
class Sub(Expr):
lhs: Expr
rhs: Expr
def accept(self, visitor: Visitor):
return visitor.visit_sub(self)
@dataclass
class Mul(Expr):
lhs: Expr
rhs: Expr
def accept(self, visitor: Visitor):
return visitor.visit_mul(self)
@dataclass
class Div(Expr):
lhs: Expr
rhs: Expr
def accept(self, visitor: Visitor):
return visitor.visit_div(self)
由此我们注意到访问者模式的第三个细节:每一个 Expr 的子类的 accept 只需要调用对应的 visit_* 方法即可。比如 Number.accept 方法只需要调用 visitor.visit_number(self)
现在我们试想一下:当我们调用 instance.accept 方法的时候会发生什么
- 第一次分派:根据
instance的运行时类型找到对应的accept方法实现 - 第二次分派:所有的
accept方法本质上都是调用的visitor.visit_*方法,此时会根据visitor的运行时类型找到对应的visit_*方法实现
可以看到,访问者模式确实通过类之间的关系实现了双分派。接下来我们会通过实现 2 个 Visitor 以更好理解双分派的过程
Visitor 的实现
在讨论具体的 Visitor 之前,我们先给出本文例子对应的 Visitor 抽象基类的 Python 代码
class Visitor(ABC):
@abstractmethod
def visit_number(self, e: "Number"): ...
@abstractmethod
def visit_add(self, e: "Add"): ...
@abstractmethod
def visit_sub(self, e: "Sub"): ...
@abstractmethod
def visit_mul(self, e: "Mul"): ...
@abstractmethod
def visit_div(self, e: "Div"): ...
可以看到,它跟我们前面的类图是对应的,每一个 AST 节点类型都有对应的 visit_* 方法
PrettyPrinter
PrettyPrinter 对应有副作用的 Visitor 实现,也就是每一个 visit_* 都没有返回值,我们只为了副作用(在这里是打印 AST)
对于 AST 来说,一个常见的需求是将其转化成人类可读的字符串。显然,你可以通过递归遍历一个 AST 树实现打印的逻辑,只需要注意每一个节点如何处理即可。以本文的整数四则运算为例,我们需要实现如下的几个 visit_* 方法
visit_number:打印数字visit_add:递归打印lhs,打印+,递归打印rhsvisit_sub:递归打印lhs,打印-,递归打印rhsvisit_mul:递归打印lhs,打印*,递归打印rhsvisit_div:递归打印lhs,打印/,递归打印rhs
代码如下
class PrintVisitor(Visitor):
def visit_number(self, e: Number):
print(e.val, end="")
def visit_add(self, e: Add):
e.lhs.accept(self)
print(" + ", end="")
e.rhs.accept(self)
def visit_sub(self, e: Sub):
e.lhs.accept(self)
print(" - ", end="")
e.rhs.accept(self)
def visit_mul(self, e: Mul):
e.lhs.accept(self)
print(" * ", end="")
e.rhs.accept(self)
def visit_div(self, e: Div):
e.lhs.accept(self)
print(" / ", end="")
e.rhs.accept(self)
于是对于如下的 AST 结构
expr = Add(Number(1), Mul(Number(2), Number(3)))
你可以直接使用该 Visitor 打印出人类可读的表达式
expr.accept(PrintVisitor())
# output: 1 + 2 * 3
Interpreter
Interpreter 对应无副作用的 Visitor 实现,也就是每一个 visit_* 都有返回值
接下来我们为整数四则运算的 AST 实现一个基本的解释器,即遍历 AST 的同时计算对表达式进行求值。和前面的例子一样,我们只需要想清楚,对于每一个类型的 AST 节点要做什么即可
visit_number:直接返回对应的整数visit_add:计算lhs子树对应的表达式的值,然后计算rhs子树对应的表达式的值,求和然后返回visit_sub:和上面的类似,但是做减法再返回visit_mul:和上面的类似,但是做乘法再返回visit_div:和上面的类似,但是做除法再返回
我们不难得到如下的代码
class Interpreter(Visitor):
def visit_number(self, e: Number) -> int:
return e.val
def visit_add(self, e: Add) -> int:
return e.lhs.accept(self) + e.rhs.accept(self)
def visit_sub(self, e: Sub) -> int:
return e.lhs.accept(self) - e.rhs.accept(self)
def visit_mul(self, e: Mul) -> int:
return e.lhs.accept(self) * e.rhs.accept(self)
def visit_div(self, e: Div) -> int:
return e.lhs.accept(self) / e.rhs.accept(self)
还是前面的例子
expr = Add(Number(1), Mul(Number(2), Number(3)))
print(expr.accept(Interpreter()))
# output: 7
优缺点
访问者模式的优点是很清晰的
- 它符合开闭原则。前面的例子我们可以看到,我们可以无论增加多少个新的访问者,被访问者(AST)的代码本身都不需要做改动
- 它确实通过类之间的关系实现了双分派
它的缺点可能一开始还看不出来。但我们不妨设想一下:如果我的 AST 里需要新增一个节点类型(假设叫做 NewNode),那么会发生什么?所有已经实现的 Visitor全都需要增加一个新的 visit_newnode 方法
所以我们可以得出一个结论:当被访问者的数据结构不怎么发生变化,而你又确实需要双分派的时候,就可以用访问者模式。这解释了为什么程序分析工具里会经常看到访问者模式,因为编程语言的 AST 通常很稳定,各种程序分析工具又需要根据 AST 节点类型实现不同的逻辑
总结
大多数情况下 OOP 自带的单分派已经可以满足你的编程需求,你多半不需要使用访问者模式,除非你的工作跟访问者模式的典型应用场景强相关(比如需要做程序分析的我)。在我看来,访问者模式的本质就是通过模拟 2 次单分派实现了双分派。换言之,如果下次你发现你的编程场景里可以用双分派,而你使用的编程语言不提供该机制的时候,你就可以大胆告诉你的 Coding Agent 说使用访问者模式进行代码的编写 :)