很多C++开发者对访问者模式的最初印象,大概率来自《设计模式》那本书里的经典图示,或者面试八股文里关于“双分派”的问答。学了之后放进工具箱,却很少真正用起来。我做了十几年C++,真正大规模用上访问者模式是在处理AST、异构容器和消息协议转换这些场景之后,才意识到它的价值远超教科书上那几行例子。这篇东西我想把访问者模式从“认识它”推到“用好它”的层面,聊聊多分派的本质、经典实现里那些容易绊倒人的细节,以及现代C++(尤其std::variant和std::visit)给它带来的新解法,最后用几个我能落地的实战场景做收尾。适合对C++有一定基础、想深入了解模式本质和现场选型的人——如果你正为“要不要用访问者、还是直接用std::visit”纠结,这篇应该能给你一个明确答案。
1. 多分派的本质:访问者模式为什么总被误解
访问者模式在C++社区里口碑两极分化很严重。有人说它丑陋、难维护,有人说它是处理复杂对象结构的神器。我的观点是,骂它的人多半没用对场景,而夸它的人往往也只是套过一两个模板。要真正搞清楚这个模式,得先回到它最核心的动机:运行时多分派。
1.1 虚函数只解决单分派
C++面向对象里,虚函数是最基础的多态手段。但绝大多数时候虚函数只实现了单分派——也就是说,最终调用哪个函数,只取决于某个对象的动态类型。你有一个Shape*,调用draw(),运行时根据它实际指向的是Circle还是Rectangle决定调用哪个实现。这是一个对象的动态类型参与决策,所以叫单分派。
但现实世界的很多逻辑需要两个(甚至更多)动态类型共同参与决策。最经典也最常被提起的例子是碰撞检测。假设你要判断两个形状是否相撞,行为不是由“第一个形状”单独决定的,也不是由“第二个形状”单独决定的,而是由“两者组合”决定的。
用虚函数写两个形状的碰撞,传统解法是两层虚调用:first->collide(second),Circle的collide里再根据second的类型写一堆dynamic_cast分支。这就是典型的“手工分派”,又臭又长,而且每加一个新类型就要改动老代码。
访求者模式正是为这类问题设计的:它把“两个动态类型决定行为”这个复杂问题拆成两次单分派,第一次由被访问元素的类型决定调用哪个重载,第二次由访问者的类型决定具体进入哪个分支。
code复制先别急着背结论,记住这句话:访问者模式不是遍历器,不是迭代器的替代品,它解决的是分派问题,遍历只是它常被顺带使用的场景。
1.2 数据与操作分离
访问者模式第二个重要价值是“数据与操作分离”。如果你有一组相对稳定的元素类(例如编译器里的AST节点:IfStmt、WhileStmt、CallExpr),它们的核心结构变化很慢,但针对它们的操作却变化频繁——语义分析、代码生成、格式化、统计行数、死代码消除……
传统面向对象写法是把每个操作分散到各个元素类里,IfStmt里加一个semanticAnalyze(),WhileStmt里也加一个semanticAnalyze(),等到新增一个操作,所有类都要跟着动,改到想吐。
访问者模式的做法反过来:元素类只保留一个accept(Visitor&),把操作算法放进独立的Visitor类里,每个Visitor只负责一种操作。新增操作时,元素类一动不动,只新增一个Visitor类。如果你的元素集合足够稳定、操作集合持续膨胀,这个方向的收益是特别明显的。
1.3 从几何对象到AST:两种典型场景的差别
访问者模式最常见的两类用法,虽然代码结构类似,但设计动机有点不一样。
第一种是几何/图形系统,类似Shape/Element的例子。这类场景的出发点是多分派——碰撞检测、相交计算、格式导出,都依赖两个类型的组合。第二种是编译器/解释器,Element是AST节点,Visitor是遍历器或者分析器。这类场景的出发点是操作分离——AST节点类型稳定,但各种编译pass不断涌现。
如果你现在碰到的场景两者都不占(元素类型频繁变化、操作数量也很少,或者分派根本不依赖多类型组合),那访问者模式大概是错的工具。别硬套,工具箱里别的锤子也许更合适。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典实现里的坑:Accept、返回值、循环依赖
聊完模式背后的本质,接下来进入实操层面。访问者模式教科书般的实现框架并不复杂,但一进入真实项目,你会发现很多细节并不像书里讲得那么丝滑。
2.1 最小可运行骨架
先给一个传统继承体系下的最小骨架,这是所有讨论的基础。
cpp复制#include <iostream>
#include <memory>
#include <vector>
class Visitor;
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor& v) = 0;
};
class ConcreteElementA;
class ConcreteElementB;
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(ConcreteElementA& a) = 0;
virtual void visit(ConcreteElementB& b) = 0;
};
class ConcreteElementA : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
// A特有的接口
};
class ConcreteElementB : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
// B特有的接口
};
class PrintVisitor : public Visitor {
public:
void visit(ConcreteElementA& a) override {
std::cout << "A" << std::endl;
}
void visit(ConcreteElementB& b) override {
std::cout << "B" << std::endl;
