访问者模式:当数据结构遇上操作,谁说了算?

说实话,我第一次接触访问者模式时,脑子里冒出的第一个念头是——「这不就是函数重载的升级版吗?」后来在项目中真正用上它,才明白这玩意儿解决的是个挺棘手的问题:当你的数据结构相对稳定,但操作却频繁变化时,该怎么办?

我有个老项目,里面维护着一套抽象语法树(AST),节点类型就那么十几种。但业务方今天要加个「代码复杂度分析」,明天要加「变量作用域检查」,后天又要「死代码检测」。每次加新功能,我都得去改那十几个节点类,加一堆方法。改得多了,类就变得臃肿不堪,而且容易引入 bug。

后来我重构了那套代码,用的就是访问者模式。说白了,就是把「操作」从「数据结构」里抽出来,让它们各自独立演化。

核心思想:数据结构与操作分离

访问者模式的核心,用一句话概括就是:允许你在不改变元素类的前提下,定义作用于这些元素的新操作

你想想看,传统的做法是「操作属于对象」——你想对某个对象做点什么,就得在它身上加方法。但访问者模式反过来了:它把操作封装成一个独立的「访问者」对象,让它「访问」数据结构中的每个元素,然后执行对应的操作。

关键角色:

  • Visitor(访问者):声明一组访问方法,每种元素类型对应一个 visit 方法
  • ConcreteVisitor(具体访问者):实现具体的操作逻辑
  • Element(元素):声明一个 accept 方法,接收访问者
  • ConcreteElement(具体元素):实现 accept 方法,调用访问者对应的 visit 方法
  • ObjectStructure(对象结构):管理元素集合,通常提供迭代能力

Double Dispatch:双重分派的秘密

这里有个关键点,也是很多人容易搞混的地方——Double Dispatch

Java 和 C++ 都是单分派语言。什么意思呢?就是方法调用时,只有调用者的实际类型会被动态解析,参数类型是在编译期就定死的。但访问者模式通过两次调用,实现了「根据调用者和参数的实际类型共同决定调用哪个方法」的效果。

第一次分派:element.accept(visitor) —— 根据元素的真实类型,调用对应的 accept 方法。

第二次分派:visitor.visit(this) —— 在 accept 方法内部,根据访问者的真实类型,调用对应的 visit 方法。

这两次分派加在一起,就实现了 Double Dispatch。嗯,这里要注意:如果没有 Double Dispatch,访问者模式就失去了灵魂。我曾经见过有人把 accept 方法写成 visitor.visit((Element)this),结果所有元素都调到了同一个 visit 方法,那还不如直接用 if-else 呢。

C++ 实现:一个完整的例子

咱们来看一个 C++ 的实现。假设我们有一个简单的表达式树,包含数字节点和加法节点。我们要实现两个操作:计算表达式的值,以及输出表达式的文本表示。

#include <iostream>
#include <vector>
#include <memory>

// 前置声明
class Visitor;
class NumberNode;
class AddNode;

// 抽象元素
class Node {
public:
    virtual ~Node() = default;
    virtual void accept(Visitor& v) = 0;
};

// 抽象访问者
class Visitor {
public:
    virtual ~Visitor() = default;
    virtual void visit(NumberNode& node) = 0;
    virtual void visit(AddNode& node) = 0;
};

// 具体元素:数字节点
class NumberNode : public Node {
public:
    int value;
    explicit NumberNode(int v) : value(v) {}
    
    void accept(Visitor& v) override {
        v.visit(*this);  // 第二次分派
    }
};

// 具体元素:加法节点
class AddNode : public Node {
public:
    std::unique_ptr<Node> left;
    std::unique_ptr<Node> right;
    
    AddNode(std::unique_ptr<Node> l, std::unique_ptr<Node> r)
        : left(std::move(l)), right(std::move(r)) {}
    
    void accept(Visitor& v) override {
        v.visit(*this);  // 第二次分派
    }
};

// 具体访问者:计算器
class Calculator : public Visitor {
public:
    int result = 0;
    
    void visit(NumberNode& node) override {
        result = node.value;
    }
    
    void visit(AddNode& node) override {
        node.left->accept(*this);   // 第一次分派
        int leftVal = result;
        node.right->accept(*this);  // 第一次分派
        int rightVal = result;
        result = leftVal + rightVal;
    }
};

// 具体访问者:文本输出
class Printer : public Visitor {
public:
    std::string output;
    
    void visit(NumberNode& node) override {
        output = std::to_string(node.value);
    }
    
    void visit(AddNode& node) override {
        node.left->accept(*this);
        std::string leftStr = output;
        node.right->accept(*this);
        std::string rightStr = output;
        output = "(" + leftStr + " + " + rightStr + ")";
    }
};

int main() {
    // 构建表达式: (1 + 2) + 3
    auto tree = std::make_unique<AddNode>(
        std::make_unique<AddNode>(
            std::make_unique<NumberNode>(1),
            std::make_unique<NumberNode>(2)
        ),
        std::make_unique<NumberNode>(3)
    );
    
    Calculator calc;
    tree->accept(calc);
    std::cout << "计算结果: " << calc.result << std::endl;
    
    Printer printer;
    tree->accept(printer);
    std::cout << "表达式: " << printer.output << std::endl;
    
    return 0;
}

运行结果:

计算结果: 6
表达式: ((1 + 2) + 3)

你看,我们给同一个树结构传入了不同的访问者,就得到了完全不同的行为。而且,如果要加一个新的操作(比如「统计节点个数」),我只需要写一个新的 Visitor 类,完全不用动 Node 的代码

SVG 结构图:访问者模式的核心流程

访问者模式 Double Dispatch 流程 客户端 对象结构 Element (accept) ConcreteElementA ConcreteElementB Visitor (visit) ConcreteVisitorA ConcreteVisitorB ① accept(visitor) ② visit(*this) 第一次分派:根据元素实际类型调用 accept → 第二次分派:根据访问者实际类型调用 visit

什么时候该用访问者模式?

说实话,这个模式不是银弹。我个人的经验是,当满足以下条件时,访问者模式会非常顺手

  • 数据结构相对稳定:元素类型不会频繁增加。如果每两周就加一种新节点,那访问者模式会让你痛不欲生——因为每加一种元素,所有访问者都得跟着改。
  • 操作频繁变化:就像我前面说的 AST 场景,操作比数据结构变得快得多。
  • 操作涉及多种不相关的元素:比如你要对 AST 做类型检查,需要同时处理变量声明、函数调用、表达式等不同类型的节点。

避坑指南:

我曾经在一个项目中,把访问者模式用在了「元素类型频繁变化」的场景里。结果每次加新元素,都要改所有访问者,改得我头皮发麻。后来我换成了 模式匹配 + std::variant(C++17),才算是解脱了。

所以,如果你用的是 C++17 或更高版本,可以考虑用 std::visit 替代传统的访问者模式,代码会更简洁,而且没有「加元素就要改所有访问者」的痛点。

优缺点一览

优点 缺点
操作与数据结构分离,符合开闭原则 增加新元素类型困难,需要修改所有访问者
新增操作方便,只需添加新的访问者 访问者需要知道元素内部细节,破坏了封装
相关操作集中在一个访问者中,便于维护 代码结构较复杂,理解门槛高
可以在不修改元素类的前提下添加新功能 如果元素类型过多,访问者接口会变得臃肿

我的小建议:

如果你在 C++ 项目中使用访问者模式,可以考虑用 CRTP(奇异递归模板模式) 来减少重复代码。另外,记得把 accept 方法声明为 virtual,否则 Double Dispatch 就失效了——我见过有人在这个坑里栽过跟头。

访问者模式是个「用得好很爽,用不好很痛」的模式。它特别适合那些数据结构稳定、操作多变的场景。如果你正在写编译器、代码分析工具、或者任何需要遍历复杂数据结构的系统,不妨试试它。

嗯,今天就聊到这儿。记住:模式是工具,不是教条。用之前先问问自己——「这个场景真的需要 Double Dispatch 吗?」


公众号:蓝海资料掘金营,微信deep3321