访问者模式大概是设计模式里被误解最多的一个。很多人把它的“标准模板”背得很顺——一个 Visitor 基类、一堆 Element 类、每个 Element 都写void accept(Visitor& v) { v.visit(*this); }——但真把它放进 C++ 项目里,很快就会遇到教科书没讲的事:递归嵌套的树结构怎么遍历才不重复写代码?多个节点协作的算法是否适合套访问者?加了十几个 visitor 之后,代码到底更清爽还是更臃肿?这篇文章不打算重复入门概念,只会围绕高级应用场景把这几个问题拆开:遍历骨架怎么设计、std::variant 这类现代工具怎么替换经典双分派、有状态访问者的状态管理,以及访问者在大代码库中的维护策略。目标是给已经写过或者正准备写访问者模式的 C++ 开发者一份能直接参考的实践经验。
1. 教科书版访问者的三个短板与重构思路
经典访问者的本质是“双重分派”:第一次 dispatch 由节点的 accept 完成,第二次由 visitor 的重载完成。这个机制本身没毛病,但它有三个在入门教程里几乎不会提的短板。
1.1 递归结构里的遍历骨架问题
举一个很常见的场景:表达式树。你定义了Literal、Add等节点,每个节点都有 accept。Visitor 基类是:
class Visitor { public: virtual ~Visitor() = default; virtual void visit(const Literal& n) {} virtual void visit(const Add& n) { n.lhs()->accept(*this); n.rhs()->accept(*this); } };注意这个默认实现里已经把“如何遍历一个二元节点”写进了基类。看起来挺方便,但麻烦随后就来。
比如你要实现一个代码生成器,遍历顺序必须调整为右子树优先,因为某些目标指令确实有求值顺序约束。于是你在自己的visit(const Add&)里重写一遍左右子树的访问顺序:
void visit(const Add& n) override { n.rhs()->accept(*this); n.lhs()->accept(*this); }再写一个常量折叠器,要求在对当前节点做处理之前,先完成两个子节点的折叠,我又要重新控制顺序。你会发现,不同 visitor 之间真正重复的不是具体操作,而是“先访问哪棵子树、访问完之后做什么”这一段遍历骨架。每个 visitor 都在用自己的方式重新实现前序、中序、后序遍历。
我见过一个比较干净的改写思路:把 traversal 单独抽成一个策略,或者让基类只负责公共的“进入/离开”时机,把当前节点的具体处理留给子类:
class TraversalVisitor : public Visitor { public: void visit(const Add& n) override { enter(n); n.lhs()->accept(*this); mid(n); n.rhs()->accept(*this); exit(n); } // enter/mid/exit 默认空实现,子类按需覆写 };这样一来,“遍历骨架”确实统一了,但马上会暴露一个新问题:子类想跳过右子树怎么办?比如短路求值时,左子树已经确定了整个表达式的结果,右子树根本不需要访问。这时把遍历决策完全固定在基类里,反而又限制了算法的自由度。所以访问者模式在递归结构里最核心的平衡点是:遍历骨架可以统一,但必须允许语义层面随时打断它。这块我在第三章“遍历短路”里再展开。
递归结构里另一个容易被忽略的细节是 const 处处传染的问题。节点里一旦存了unique_ptr或shared_ptr,lhs()和rhs()的返回类型必须和 accept 的 const 限定保持一致。访问者经常出现写了个visit(const Add&),却在函数体里想调用非 const 的接口,于是被迫在代码里写 const_cast,这类味道一旦出现,说明访问者的 const 设计早就欠账了。
1.2 跨对象访问时不伦不类的双分派
访问者模式天然是“单个对象”的操作模型,但很多算法真正需要的是一次面对两个对象。典型例子是判断两棵 AST 是否结构相等。
按教科书思路,你会写一个EqualityVisitor,但每个visit(const Add&)里都需要访问对面的另一个节点。这里就尴尬了:你调用a.accept(*this)时,this里并不知道 b 在哪;想让双分派继续发生,就必须在 visit 函数里把 b 也放进去。写成代码就变成了:
class EqualityVisitor : public Visitor { public: bool equal(const Expr& a, const Expr& b) { b_ = &b; result_ = true; a.accept(*this); return result_; } void visit(const Literal& a) override { // 想拿到 b 的动态类型,却没法再正常分派一次 } };要真正解决这个问题,要么再维护一条acceptPair的虚函数链,给每个节点写一个专门接受“外部节点 + visitor”的重载;要么干脆绕开访问者。以我实际写过的代码来看,如果类型集合小且稳定,直接typeid+static_cast反而更省事:
bool equal(const Expr& a, const Expr& b) { if (typeid(a) != typeid(b)) return false; if (typeid(a) == typeid(Literal)) { const auto& la = static_cast<const Literal&>(a); const auto& lb = static_cast<const Literal&>(b); return la.value() == lb.value(); } if (typeid(a) == typeid(Add)) { const auto& aa = static_cast<const Add&>(a); const auto& ab = static_cast<const Add&>(b); return equal(*aa.lhs(), *ab.lhs()) && equal(*aa.rhs(), *ab.rhs()); } // ... }这里不是说 typeid 方案没有维护成本,而是它至少不需要为“对比两个对象”这个需求重新设计一条完整的分派链路。访问者模式的经典场景是“一个结构 + 多种算法”,跨对象对比更像是“两个结构 + 一种算法”,硬套 visitor 会让双分派变成四不像。我的实践结论是:遇到跨对象访问时先别急着套 visitor,先判断类型体系是否稳定。如果稳定,用枚举、typeid、甚至直接比较节点状态都更直观。
1.3 两个方向的扩展博弈:加类型还是加操作
经典访问者的“开放”方向是加操作:新增一个Evaluator、一个PrettyPrinter,不需要动任何节点类。但它对“加类型”非常不友好:给体系里加一个Negate节点,所有现有 visitor 类的visit重载集都要补一个方法,少补一个,行为就缺一块。
这个性质经常被概括成“开操作、闭类型”,但很多人没有体会到实际代价。当表达式体系需要新增一种运算时,你面临的是一个编译期的连锁修改:全局搜一遍继承自Visitor的类,数一数有多少个,每个都要补visit(const Negate&)。如果团队里有人偷懒,用基类提供的“默认空实现”来绕开编译错误,之后新节点的操作会被静默跳过,排查起来极费劲。
所以我在项目里通常约定:基类默认行为必须是显式报错,不允许静默忽略,除非某个 visitor 的业务语义本来就允许忽略陌生节点。比如调试打印器可以忽略新节点,但求值器和类型检查器绝不能漏。这个约定在工程上比任何技巧都重要,因为它把“访问者漏实现”从静默 bug 变成了编译错误或者运行期显式失败。这个权衡在下一章的 std::variant 路线上会呈现出完全不同的面貌。
2. std::visit 路线:双分派、组合访问者与性能表现
C++17 把std::variant和std::visit带进标准库之后,访问者模式的实现有了第二个主流路线。它没有继承、没有虚函数、没有 accept,但背后仍然是同一句指导思想:把操作和数据结构分开。
2.1 为什么说 variant 是封闭类型集合里的最优载体
std::variant<Literal, Negate, Add>本质上是一个带类型标签的联合体。它天然适合表达“数量在编译期就固定下来”的节点体系。与经典继承相比,它最明显的好处是结构上强制封闭:想往 variant 里塞一个新类型,必须一次性修改类型列表,所有使用了 variant 的代码都会跟着编译报错。
这看起来很像是“加类型很痛苦”的缺点,但反过来想,如果产品需求本来就明确了类型集合长期不变,那封闭性就是优点:你可以获得穷尽性检查,不用担心基类里埋着一个“忽略新类型”的默认实现。我见过不少团队的第一反应是“有继承体系为什么要换成 variant”,其实两者面对的形态不一样:经典访问者处理“开放类型 + 开放操作”中的一侧;variant + visit 则适合“封闭类型 + 任意操作”。
用递归表达式树举例:
struct Literal { double value; }; struct Negate { std::shared_ptr<Expr> expr; }; struct Add { std::shared_ptr<Expr> lhs, rhs; }; using Expr = std::variant<Literal, Negate, Add>;这里用shared_ptr而不是unique_ptr是因为,写std::visit时经常要拷贝当前访问器或者传递节点引用,虽然unique_ptr也能通过引用传入,但shared_ptr在递归访问里写起来更顺手,也避免了一些生命周期拷贝细节问题。另一个更根本的原因是variant本身不能包含不完整类型,递归结构必须通过某种指针来打破循环。这个细节在入门帖里很少提到,第一次写的人基本都会踩一下编译错误。
2.2 overloaded 组合访问者的正确打开方式
访问者本体就是一个函数对象集合。最舒服的写法是利用推导指南构造overloaded:
template<typename... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template<typename... Ts> overloaded(Ts...) -> overloaded<Ts...>;然后:
struct Evaluator { double operator()(const Literal& l) const { return l.value; } double operator()(const Negate& n) const { return -std::visit(Evaluator{}, *n.expr); } double operator()(const Add& a) const { return std::visit(Evaluator{}, *a.lhs) + std::visit(Evaluator{}, *a.rhs); } };每个operator()都对应经典 visitor 里的一个visit重载,分派由std::visit自动完成:它拿到 variant 当前的类型,然后把对应的operator()调用出来。这个写法的好处第一是安全——某个类型漏了处理,编译期根本过不去;第二是遍历逻辑可以按需要写,不会像经典 visitor 那样陷入骨架重复的泥潭。
另一个我常用的场景是把临时处理函数直接拼起来,不单独定义类。比如写一个打印表达式的调试函数:
auto print = overloaded{ [](const Literal& l, std::ostream& os) { os << l.value; }, [](const Negate& n, std::ostream& os) { os << '-'; }, [](const Add& a, std::ostream& os) { os << '('; }, };不过这里有个实战陷阱:lambda 想要递归调用自身时,overloaded变量在其自身初始化器中捕获自己会有生命周期上的模糊性。我的经验是 C++17 下尽量用std::function做显式包装,或者把递归调用拆到另一个函数里:
std::function<double(const Expr&)> eval; eval = overloaded{ [&](const Literal& l) { return l.value; }, [&](const Negate& n) { return -eval(*n.expr); }, [&](const Add& a) { return eval(*a.lhs) + eval(*a.rhs); } };很多人第一次在这里栽跟头,以为是自己代码写得不对,其实是变量的初始化域和捕获机制的问题。理解了这个坑之后,组合访问者用起来就顺畅很多。
2.3 开放类型体系不能替代,但可以借鉴理念
经典访问者和 variant 不是谁淘汰谁的关系。如果你的节点类型允许第三方插件往体系里加,那必须走继承那套双分派,因为 variant 的类型表是编译期锁死的。反过来,如果一个解释器或编译器只是自己内部用,类型集合封闭,那 variant 是明显更省心的选择。
我在一个模拟编译器工具里把一套原本用经典访问者写的 AST 处理切到 variant 之后,最大的感受不是性能改变,而是“新增操作”这件事的体验完全变了。以前要新建一个 visitor 类,现在可以直接在一个函数里用 overloaded 组合临时行为;类型处理是否穷尽,编译器直接给结论。这套组合方式即使继续使用经典继承结构也值得借鉴——把“一个操作”实现成独立的函数对象,而不是塞进一个大的 visitor 类。
3. 有状态访问者、const 分派与遍历短路
如果说前两章是在讨论“访问者怎么搭”,这一章就进入“访问者写起来之后”的日常保养问题了。三个题目看起来小,但在实际项目里出现的频率非常高:状态、const、短路。
3.1 有状态访问者:状态生命周期与线程隔离
访问者经常要携带语义信息。类型检查器需要一张符号表、一组诊断信息;优化器需要一组计数器。这些信息如果直接放在 visitor 成员变量里,最容易踩的坑是“访问者被复用”。
我见过一段类似这样的代码:
class TypeChecker : public Visitor { std::unordered_map<const Expr*, Type> types_; public: void check(const Expr& e) { types_.clear(); // 每次开始前清理 e.accept(*this); } };看起来每次调用前清空状态没问题,但如果 accept 内部在某些分支上抛了异常,types_会留下半截状态,下次调用时统计就是脏的。更隐蔽的问题是同一时间用同一个 visitor 实例处理多线程任务时,types_会成为数据竞争源。最基本的建议有三条:
- 把状态对象显式传入构造函数,而不是让 visitor 内部自己维护一堆全局数据;
- 需要复用时,用 RAII 对象做上下文进出栈,不要依赖函数末尾手动清理;
- 多线程环境里不要共享一个可变 visitor,要么每线程建一个,要么把状态抽到外部容器统一管理。
有状态访问者和遍历骨架结合时,一个常见动作是:进入节点前压栈,离开节点后弹栈。我习惯把这两步放进统一的enter/exit钩子里,并确保它们成对出现:
void visit(const Add& n) override { context_.push(/* 当前节点信息 */); n.lhs()->accept(*this); n.rhs()->accept(*this); context_.pop(); }如果 push/pop 可能被异常打断,请务必用 scope guard 保证 pop 一定会执行。访问者这种层层虚函数调用的结构里,任何“入口写了、出口忘了”的问题都会被放大成很难复现的状态泄漏。这个教训我在一个代码生成器里反复遇到过,最后把所有 push/pop 都包进了一个ContextGuard类才算根治。
3.2 const 与非 const 的分派:容易漏处理的一个角落
经典 visitor 的接口通常写成visit(const NodeType&)。理由是绝大多数操作只是“读”。但遇到 AST 变换、就地常量折叠这类需要修改节点的操作,就得有一个“可变访问者”。问题在于,C++ 的 const 语义在双分派下会变得微妙。
先看节点这一侧:
class Literal { public: void accept(Visitor& v) const { v.visit(*this); } void accept(MutableVisitor& v) { v.visit(*this); } };Literal&对象会优先选择非 const 版本,const Literal&只能选择 const 版本。这个行为符合预期,但有个常见陷阱:如果你偷懒只写了void accept(Visitor& v) const,那么编译时拿到的对象是非 const 的,它也能顺利通过这个 const 版本。结果是你想改节点的代码都悄悄走了“只读”通道。
我建议把两种 accept 分开写,并且在非 const 版本里对传入的MutableVisitor做static_assert,防止以后有人把可变操作误接到只读 visitor 上:
static_assert(std::is_base_of_v<MutableVisitor, MutableVisitor>);同时,访问者自身可能同时需要 const 和非 const 的两套 visit,接口数量很容易翻倍。一个比较实用的办法是把“访问”定义成模板化入口,让具体实现通过 if constexpr 判断处理 const 还是非 const 对象。不过这个方法在正式代码库里容易把可读性做差。我更推荐在两套 visitor 之间做显式区分:只读的继承ConstVisitor,要写 AST 的继承MutatingVisitor,两者在语义上彻底分开,宁可多敲几个字符,也不要在一个类里混用两个意图。
3.3 短路的遍历控制:停止标志与返回值设计
经典的 accept 返回 void,意味着你没法靠返回值告诉访问者“这棵子树不用继续走了”。递归结构里经常需要短路,比如常量折叠时左子树已经是 0,再访问右子树就浪费时间;或者类型检查遇到一个错误节点,后面不用再看。
最简单的办法是给 visitor 加一个bool stop_成员,每个 visit 开头检查:
bool visit(const Add& n) override { if (stop_) return false; n.lhs()->accept(*this); if (stop_) return false; n.rhs()->accept(*this); return !stop_; }这能解决一部分问题,但“检查 stop_”的样板代码又会散落各处。我后来做了一个更可控的改造:让访问接口返回一个三态结果VisitResult,取值分别为Continue、Skip、Stop;遍历骨架统一按返回值决策:
Continue:正常访问子节点;Skip:不进入当前节点的子树;Stop:终止整个遍历。
每个 visit 函数根据自己的语义返回不同的控制信号,短路逻辑从具体操作里剥离出来,收敛成遍历策略的一部分。有一点特别容易写错:Stop信号必须从内层一路透传到最外层,每一层父节点的骨架代码都要检查返回值,并把这个“停”继续向上传。透传逻辑写错不会有编译错误,只会表现为遍历停在了错误的位置,排起来相当费时间,值得先用一组小用例把行为钉死。
4. 真实项目里的访问者维护经验
设计和技巧讲完,这部分是更接近“项目现场”的内容。访问者模式在小 demo 里永远好看,放到真实项目里才会暴露维护成本。
4.1 新增节点类型那一刻,编译器的债主上门
典型场景:表达式系统本来只有Literal和Add,现在产品要求支持一元负号,给体系加一个Negate。经典访问者路线上,你马上会面对一堆 visitor 类,每一个都要补visit(const Negate&)。如果你用的是纯虚函数接口,编译器会强制列出所有漏网之鱼,这其实是好事;但如果基类里提供了空实现,编译能过,运行起来行为就可能不对了。
我后来发现一个更可控的工作流:在新节点加入之前,先全局搜一下所有继承自Visitor的类,把它们的数量和维护成本拍个照。如果发现 visitor 类的数量已经失控,那这往往是“访问者太多”的信号,说明某种系统性的重构比单纯加类型更值得做。操作类一旦超过十几个,每次新增节点都像过刑场。
在 variant 路线上,给std::variant<>类型列表加Negate同样会触发所有 visit 调用点的编译错误,但这种错误更集中:每个 visit 的地方都必须穷尽处理所有类型,漏一个就不可能编译通过。这比“基类有默认空实现”要安全得多。所以选型时我会先问一句:这个 AST 体系里的类型集合,未来一年内真的会变吗?如果答案是“基本不会”,直接上 variant;如果答案是“会有第三方扩展”,那就老实回去写经典 visitor,并用代码审查保证不出现默认忽略。
4.2 类型擦除封装访问者:让调用方忘记 Visitor 的存在
访问者具体类如果直接暴露给上层逻辑,接口会非常啰嗦:调用方要构造一个 visitor,然后手工 accept,再读取 visitor 里的结果。比如:
Evaluator eval; root.accept(eval); double result = eval.result();三步走还算能忍,但如果十几个操作都这么裸奔,代码很快就充满了 visitor 的细节。更关键的是,调用方本不应该知道“我调的是访问者”这层机制。
我常用的封装方式是把每个算法包装成一个普通函数,内部用局部访问者实现:
double evaluate(const Expr& root) { Evaluator eval; root.accept(eval); return eval.result(); }如果操作本身是一个闭包,还可以写一个通用的apply辅助函数,接收一个函数对象,把节点类型分派包在内部。用现代 C++ 写起来很像把 visitor 变成了std::function集合:
template<typename Fn> void apply(const Expr& root, Fn&& fn) { struct Adapter : Visitor { Fn& fn; void visit(const Literal& n) override { fn(n); } void visit(const Add& n) override { fn(n); } } adapter{fn}; root.accept(adapter); }做完这层封装后,业务代码里的访问者痕迹会大幅减少:调用方只看到函数和回调对象,访问者的具体策略后续可以替换成std::visit实现,而不影响外部接口。这个封装模式在我重构过的几套 AST 工具里都很有价值,属于“低成本、高收益”的维护技巧。
4.3 三种实现的实测数据与我的选型建议
我在一个用于教学的解释器模拟项目 X 里,先后用三种写法实现过同一个表达式求值器:经典 visitor、std::variant + std::visit、以及手写枚举判别式(节点对象里内置一个 type tag,然后直接 switch +static_cast)。节点类型约有五种,测试是构建一批中等深度的表达式并反复求值。基准代码和优化级别会影响结论,下面的数字只给量级参考,但趋势是明确的:
| 实现方式 | 分派机制 | 相对速度 | 维护体验 |
|---|---|---|---|
| 经典 Visitor | 两次虚函数调用 | 基准(略慢) | 类型开放好加操作,但要维护一堆类 |
| std::visit | 索引分派/跳转 | 通常略快 10%~20% | 类型封闭、穷尽检查强 |
| 手写 switch + type tag | 单次分支 | 最快 | 最灵活但最脆弱,新增类型易漏 |
最让我意外的是手写 switch 在节点类型少时并没有快出一个数量级,只快了一个零头,但维护成本却是最高的:每次加节点都要把散落在各处的 switch 找全,漏一处就是很难复现的运行时问题。所以性能不是选型的唯一标准,准确性和开发效率往往更重要。
综合下来,我的选型偏好是这样的:
- 类型集合封闭、操作会在生命周期里不断增加:首选
std::variant + std::visit; - 类型集合可能被外部扩展(插件框架、自定义节点体系):用经典访问者,并坚持基类不做静默忽略;
- 类型只有两三种、操作也不多:不要用访问者,直接一个普通函数配合枚举判断就够了。
访问者模式的价值在于隔离变化方向。当你只有一套变化方向时,它带来的抽象成本大概率是纯开销。与其把访问者当成万能模板,不如先做一次变化方向盘点再决定。
我自己的使用体会是,访问者模式是一门“真香与真臭并存”的技术:在类型封闭的多态结构里,它让新增操作变得干净利落;在类型不停扩张的体系里,每一次新增节点都是对它设计局限的提醒。如果你正在维护一个到处都是Visitor的代码库,不妨先统计一下 visitor 类的数量和扩展方向,再用上面几种思路重新审视一遍——该换std::visit的就别恋战,该做类型擦除封装的尽早封一层。最后留一个小技巧:无论走哪条路线,给访问者体系的“穷尽性”写一个编译期自检测试,专门对着新增类型和新增操作两个方向做回归,这能省掉很多运行时才暴露的半截实现问题。