目录

深入理解访问者模式:双分派的艺术

在 AI Coding 时代,代码实现已经不是瓶颈,更难的反而是你如何去约束 Coding Agent 的行为,让它可以有高质量的代码产出。这些约束一般是比较 high-level 的东西,像是设计模式、软件架构等。在这样的时代背景下,了解这些知识在我看来是更加重要的。以设计模式为例,你需要知道对应的设计模式是什么,它解决了什么问题,适用于什么场景等才能够用清晰的自然语言描述去指导 Coding Agent。今天要要介绍的就是其中一个设计模式————访问者模式(Visitor Pattern)

在深入了解访问者模式之前,我们有必要对面向对象的编程语言(下称 OOP)的分派(Dispatch 机制)有一定了解。因为访问者模式其实是帮助你更好地做双分派(Double Dispatch)。分派的意思就是:当你调用一个方法的时候,决定具体被调用的方法

在面向对象的世界里,方法调用一般遵循如下的风格(以 Python 为例)

obj.method()

在上面的代码中,一般我们称 objReceivermethod 是具体的方法调用。常见的是单分派(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 抽象基类的 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_* 方法

Note

PrettyPrinter 对应有副作用的 Visitor 实现,也就是每一个 visit_*没有返回值,我们只为了副作用(在这里是打印 AST)

对于 AST 来说,一个常见的需求是将其转化成人类可读的字符串。显然,你可以通过递归遍历一个 AST 树实现打印的逻辑,只需要注意每一个节点如何处理即可。以本文的整数四则运算为例,我们需要实现如下的几个 visit_* 方法

  • visit_number:打印数字
  • visit_add:递归打印 lhs,打印 +,递归打印 rhs
  • visit_sub:递归打印 lhs,打印 -,递归打印 rhs
  • visit_mul:递归打印 lhs,打印 *,递归打印 rhs
  • visit_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
Note

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 说使用访问者模式进行代码的编写 :)