访问者模式:当数据结构遇上操作,谁说了算?
说实话,我第一次接触访问者模式时,脑子里冒出的第一个念头是——「这不就是函数重载的升级版吗?」后来在项目中真正用上它,才明白这玩意儿解决的是个挺棘手的问题:当你的数据结构相对稳定,但操作却频繁变化时,该怎么办?
我有个老项目,里面维护着一套抽象语法树(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 结构图:访问者模式的核心流程
什么时候该用访问者模式?
说实话,这个模式不是银弹。我个人的经验是,当满足以下条件时,访问者模式会非常顺手:
- 数据结构相对稳定:元素类型不会频繁增加。如果每两周就加一种新节点,那访问者模式会让你痛不欲生——因为每加一种元素,所有访问者都得跟着改。
- 操作频繁变化:就像我前面说的 AST 场景,操作比数据结构变得快得多。
- 操作涉及多种不相关的元素:比如你要对 AST 做类型检查,需要同时处理变量声明、函数调用、表达式等不同类型的节点。
避坑指南:
我曾经在一个项目中,把访问者模式用在了「元素类型频繁变化」的场景里。结果每次加新元素,都要改所有访问者,改得我头皮发麻。后来我换成了 模式匹配 + std::variant(C++17),才算是解脱了。
所以,如果你用的是 C++17 或更高版本,可以考虑用 std::visit 替代传统的访问者模式,代码会更简洁,而且没有「加元素就要改所有访问者」的痛点。
优缺点一览
| 优点 | 缺点 |
|---|---|
| 操作与数据结构分离,符合开闭原则 | 增加新元素类型困难,需要修改所有访问者 |
| 新增操作方便,只需添加新的访问者 | 访问者需要知道元素内部细节,破坏了封装 |
| 相关操作集中在一个访问者中,便于维护 | 代码结构较复杂,理解门槛高 |
| 可以在不修改元素类的前提下添加新功能 | 如果元素类型过多,访问者接口会变得臃肿 |
我的小建议:
如果你在 C++ 项目中使用访问者模式,可以考虑用 CRTP(奇异递归模板模式) 来减少重复代码。另外,记得把 accept 方法声明为 virtual,否则 Double Dispatch 就失效了——我见过有人在这个坑里栽过跟头。
访问者模式是个「用得好很爽,用不好很痛」的模式。它特别适合那些数据结构稳定、操作多变的场景。如果你正在写编译器、代码分析工具、或者任何需要遍历复杂数据结构的系统,不妨试试它。
嗯,今天就聊到这儿。记住:模式是工具,不是教条。用之前先问问自己——「这个场景真的需要 Double Dispatch 吗?」