访问者模式:数据结构与操作分离,双分派技术
访问者模式,说白了就是解决一个经典矛盾:稳定的数据结构和频繁变化的行为之间怎么相处。
我刚开始接触这个模式时,觉得它有点绕。后来在编译器项目里处理AST(抽象语法树)时,才真正体会到它的威力。你想想看,AST的节点类型基本固定——表达式、语句、声明这些,但你要在上面做的操作可就多了:类型检查、代码生成、优化、格式化……每加一种操作,难道都要改所有节点类吗?
访问者模式就是来解这个题的。
核心思想:把操作“外包”出去
传统做法是把操作写在类里面。比如一个BinaryExpr类,里面有个eval()方法。但这样每加一个新操作,就得改这个类。
访问者模式反其道而行:节点类只负责接受“访问者”,具体操作全放在访问者里。
打个比方:
- 节点类就像博物馆里的展品——它们自己不动,只提供一个“接待入口”
- 访问者就像不同的导游——有的讲历史,有的讲艺术,有的讲建筑
- 展品不需要知道导游要讲什么,它只需要说“欢迎参观”
双分派:C++里的“两次选择”
这里有个技术难点。C++的函数重载是静态绑定的,靠编译期类型决定调用哪个重载版本。但访问者模式需要运行时根据实际类型来调用对应的处理函数。
怎么解决?用“双分派”。
第一次分派:调用node->accept(visitor),根据node的实际类型,进入对应的accept实现。
第二次分派:在accept内部,调用visitor->visit(this),这里的this已经是具体类型了,编译器能正确匹配重载版本。
我当初看这段代码时,觉得这招真妙——用两次单分派模拟出了双分派的效果。
代码示例:简易AST处理
咱们写个简化版的编译器AST处理。假设有三种节点:数字、二元运算、变量引用。
// 前置声明
class Visitor;
// 抽象节点
class ASTNode {
public:
virtual ~ASTNode() = default;
virtual void accept(Visitor& v) = 0;
};
// 具体节点
class NumberExpr : public ASTNode {
public:
int value;
explicit NumberExpr(int v) : value(v) {}
void accept(Visitor& v) override {
v.visit(*this); // 第二次分派
}
};
class BinaryExpr : public ASTNode {
public:
char op;
ASTNode* left;
ASTNode* right;
BinaryExpr(char o, ASTNode* l, ASTNode* r)
: op(o), left(l), right(r) {}
void accept(Visitor& v) override {
v.visit(*this);
}
};
class VarExpr : public ASTNode {
public:
std::string name;
explicit VarExpr(std::string n) : name(std::move(n)) {}
void accept(Visitor& v) override {
v.visit(*this);
}
};
现在定义访问者接口:
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(NumberExpr&) = 0;
virtual void visit(BinaryExpr&) = 0;
virtual void visit(VarExpr&) = 0;
};
实现一个求值访问者:
class EvalVisitor : public Visitor {
public:
void visit(NumberExpr& node) override {
result = node.value;
}
void visit(BinaryExpr& node) override {
node.left->accept(*this);
int leftVal = result;
node.right->accept(*this);
int rightVal = result;
switch (node.op) {
case '+': result = leftVal + rightVal; break;
case '-': result = leftVal - rightVal; break;
case '*': result = leftVal * rightVal; break;
case '/': result = leftVal / rightVal; break;
}
}
void visit(VarExpr& node) override {
// 假设有符号表
result = symbolTable[node.name];
}
int getResult() const { return result; }
private:
int result = 0;
std::map<std::string, int> symbolTable;
};
使用起来很直观:
ASTNode* tree = new BinaryExpr('+',
new NumberExpr(3),
new BinaryExpr('*',
new NumberExpr(4),
new NumberExpr(5)
)
);
EvalVisitor ev;
tree->accept(ev);
std::cout << ev.getResult(); // 输出 23
SVG:访问者模式核心流程
编译器AST处理:实战场景
我在做C++编译器前端时,AST节点大概有50多种。如果每种操作都写成虚函数,每个节点类得维护十几个方法,代码根本没法看。
用了访问者模式后,每种操作变成一个独立的访问者类:
| 访问者 | 职责 |
|---|---|
| TypeCheckVisitor | 类型检查,确保表达式类型匹配 |
| CodeGenVisitor | 生成中间代码或目标代码 |
| OptimizeVisitor | 常量折叠、死代码消除等优化 |
| PrintVisitor | 打印AST结构,用于调试 |
| SymbolResolveVisitor | 符号解析,建立作用域链 |
加一种新操作?写个新访问者就行。节点类一个字符都不用改。这就是开闭原则的体现——对扩展开放,对修改关闭。
核心收益:访问者模式把“数据”和“操作”解耦到极致。数据类稳定,操作类可以无限扩展。
避坑指南
我曾经踩过的坑:
- 循环依赖:访问者和节点互相引用,头文件包含要小心。用前置声明解决。
- 忘记处理所有节点类型:新增节点类型后,所有访问者都得加对应
visit方法。编译器不会提醒你漏了哪个。 - 性能问题:每个节点都要两次虚函数调用。在性能敏感场景(比如实时编译),可以考虑用CRTP模式优化。
我的个人习惯:
给访问者接口加一个默认实现,抛出异常或返回错误。这样新增节点类型时,旧访问者至少能编译通过,运行时再暴露问题。比编译期直接报错更友好。
什么时候用?什么时候别用?
适合用:
- 数据结构稳定,操作频繁变化(比如AST、XML DOM树)
- 需要对一个对象结构做多种不相关的操作
- 操作之间没有共享状态,各自独立
别硬用:
- 数据结构经常变化——每加一种节点,所有访问者都得改,维护成本爆炸
- 操作很少,就一两个——用虚函数或std::variant更简单
- 需要频繁访问节点内部私有成员——访问者模式会破坏封装
嗯,访问者模式就是这么个东西。它不常用,但用对地方就是神器。下次你写AST处理器或者某种树形结构时,不妨想想它。