☰
C++访问者模式:原理、陷阱与std::variant替代实现
2026/10/11 21:49:54 网站建设 项目流程

如果你维护过一类需要频繁新增操作的类型集合——比如图形系统里的 Shape、渲染器里的 Resource、表达式解释器里的 AST Node——大概率有过一个越改越难受的过程:想让某个操作对全部具体类型生效,就得在继承树的每个分支里重复写分发代码。访问者模式把这层分发收敛成稳定的 Accept 接口,让操作以 Visitor 对象的形式注入类型层级。但 C++ 的对象模型不允许做真正的运行时双分派,于是实现时既要利用重载决议这条编译期静态机制,又要靠虚函数承担另一层动态分派,两者配合稍不留神就会出现返回值和 const 正确性的连锁问题。这篇文章会先讲清楚访问者模式的原理与适用边界,然后带你看几段可以抄进项目的现代 C++ 写法,包括 std::variant 和 std::visit 的替代实现方式,最后整理一批真实项目里遇到的编译报错和排查思路。适合已经掌握 C++ 基础、想把多态代码拆得更干净的人。

1. 换个角度理解访问者模式:先解决继承树上的困局

1.1 访问者模式到底在解决什么问题

教科书里通常把访问者模式归到行为型模式里,但我觉得更本质的说法是:它处理的是“双分派”。普通虚函数只按调用方的动态类型分派一次,而访问者模式希望按“被访问对象的具体类型”和“访问者自己的具体类型”同时做分派。举一个实际例子:假设几何库里有两个图元 Circle 和 Rectangle,都继承自 Shape。传统多态写法是每个类实现一个虚方法 Draw,绘图器直接调用 Shape.Draw()。可当绘图操作越来越多,比如光栅化、写成 SVG、做碰撞检测、序列化成 JSON、计算面积,每加一类操作,所有图元类都得跟着改一遍。这显然违背开闭原则:类应该是扩展开放、修改关闭的。

访问者的做法是把“操作”从“类型层级”里抽出去。图元类只需要留一个稳定的 Accept 方法,每个新操作都做成独立的 Visitor。以后想加“导出成 JSON”这个操作,不再动 Circle、Rectangle 的代码,只写一个 JsonExportVisitor 就能完成。这套思路在一开始不太直观,因为它把代码的组织方向倒过来了:以前是“我是圆,我知道怎么画自己”,现在是“我是圆,我接受一个外部访问者,具体怎么处理我由访问者决定”。

需要说明的是,C++ 不像 C# 有 dynamic 运行期双分派机制,也没有某种“运行时反射”能猜出对象的真实类型并自动选重载。所以 C++ 中的访问者实现,必须依靠两个不同机制配合:第一层是虚函数,解决 Listen 端(Shape 这一侧)的动态分派;第二层是重载决议,解决 Visit 端(Visitor 这一侧)的编译期静态匹配。理解了这两层的职责分工,后面很多“为什么这么写”的问题就迎刃而解。

1.2 GoF 经典代码骨架,以及最容易被误解的 Accept

GoF 书里的访问者结构已经足够清晰,换到 C++ 里通常长这样:

class ShapeVisitor; class Shape { public: virtual ~Shape() = default; virtual void Accept(ShapeVisitor& visitor) = 0; }; class Circle : public Shape { public: explicit Circle(double radius) : radius(radius) {} void Accept(ShapeVisitor& visitor) override { visitor.Visit(*this); } double radius; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : w(w), h(h) {} void Accept(ShapeVisitor& visitor) override { visitor.Visit(*this); } double w; double h; };

访问者是另一组接口:

class ShapeVisitor { public: virtual ~ShapeVisitor() = default; virtual void Visit(const Circle& shape) = 0; virtual void Visit(const Rectangle& shape) = 0; };

很多初学 C++ 的人第一次看到visitor.Visit(*this)会愣一下,其实这一行的精妙之处在于*this的类型在编译期就确定。Circle 里的this是Circle*,所以传给 Visit 的参数静态类型就是const Circle&,编译器一定选到Visit(const Circle&)那个重载;Rectangle 同理。这里的Accept是虚函数,所以从Shape*进入时,运行期先动态分派到具体子类的 Accept,再在 Accept 内部完成一次静态的重载选择。两条分派路径一虚一实,被拆得干干净净。

这带来了一个关键的陷阱:访问者接口里的参数类型务必写成具体类型,不能图省事都写成const Shape&。如果 Visit 只接收基类引用,无论哪个子类调用都会走进同一个函数,访问者模式就等于完全失效。你得到的不是报错,而是静默的错误逻辑,这类问题在代码审查里最容易漏过。

1.3 什么时候该用它,什么时候别碰

选型比实现难。我的判断依据很简单:如果类型集合相对固定,但操作集合频繁扩张,就可以考虑访问者模式。因为类型固定意味着 Visitor 接口一旦写好,后续很少因为新类型去改动;操作频繁扩张意味着每次写新功能只是加一个新的 Visitor 类,完全不用改原有类型层级。反过来,如果系统经常要加新类型,比如每个月都会进来一种新的图元形状,那么访问者模式就会变成包袱——每加一个类型,所有已有 Visitor 类都要去补一个 Visit 重载,否则要么编译失败,要么靠默认实现兜底。

还有一种情况也不建议硬上:类层次本身已经很混乱,继承层级又深又多。访问者需要大家配合维护接口,一旦出现三四个层级深浅不一的中间类,重载决议会很复杂,反而把简单问题弄复杂。此时用更朴素的虚函数,甚至直接 switch 类型枚举,可能都比强行套访问者要好。如果你只是想解决一个分支爆炸的问题,又恰好能把类型集合收拢到固定范围内,那可以直接跳到第 3 节,用 std::variant 实现同一套思路,代码量会少很多。

2. C++ 里那些容易跑偏的实现细节

2.1 重载决议陷阱:纯虚重载与“默认兜底”怎么共存

访问者接口最忌讳只写部分类型。但实际工程里经常遇到一种需求:访问者只关心少数几个类型,其余类型直接忽略或统一处理。这时候你不会想在自己写的每个 Visitor 里都把所有类型重载写上,于是有人会把 Visitor 基类里的全部方法做成有默认实现的虚函数:

class ShapeVisitor { public: virtual ~ShapeVisitor() = default; virtual void Visit(const Circle&) {} virtual void Visit(const Rectangle&) {} virtual void Visit(const Line&) {} };

子类只需要 override 关心的那一个。这种“默认兜底”模式非常省事,但代价是丢失了编译期强制力。如果某个具体子类忘记在 Visitor 里实现对应类型,不会有人提醒你——编译正常,运行期悄悄走默认分支。这是访问者模式最常见的可靠性黑洞。我在项目里的折中做法是:默认实现不真的留空,而是在里面放断言,比如 debug 构建下assert(false)或std::terminate(),release 构建再决定是忽略还是记录日志。这样行为可预期,调试时也不会静默吞掉问题。

另一个重载相关的问题是混淆Visit的常量性。如果你既想支持Visit(Circle&)又想支持Visit(const Circle&),两种重载同时存在时,只能靠调用方的 const 属性来选择。这看起来没什么,但多态调用时麻烦就来了:通过基类引用传入的对象,在Shape::Accept内部你会拿到this的具体类型,但如果 Accept 参数是const Shape&还是非 const,会直接决定你调用Visit(const Circle&)还是Visit(Circle&)。接口设计一定要提前想清楚只读还是可写,我后文会展开。

2.2 返回值、const 正确性与状态访问者

GoF 经典访问者把所有 Visit 都设计成 void,这在实际工程里不够用。让访问者返回结果有三个常见方案:

第一种是给每个 Visit 方法一个结果输出参数,比如void Visit(const Circle&, double& area)。这种写法直白,但参数一多会让接口膨胀,而且组合多个结果时要额外定义结构体。

第二种是让访问者类自己持有状态,所有 Visit 方法都向内 accumulate。比如写一个 AreaCalculator,内部维护总和,访问者被遍历过之后通过TotalArea()取结果。这个方案极其常用,因为它天然支持“遍历多次合并结果”,而且不必修改接口签名。缺点是不能并行,如果未来要并发遍历,内部状态很容易成为数据竞争点。

第三种是用模板或 auto 返回值模拟泛型访问者,比如template<typename T> Ret Visit(const T&),但这实际上放弃了一部分多态能力,通常只在静态访问者里配合 std::variant 使用。

const 正确性是我在真实项目里最常看到的问题。很多访问者接口把参数写成普通引用Visit(Circle&),导致你不能对const std::vector<std::unique_ptr<Shape>>&做遍历,因为容器里的元素无法转换成非 const 引用。常规解法是同时提供 const 和非 const 的 Accept 重载,以及访问者接口里对应类型的 const 版本。设计优先考虑“只读遍历”场景,默认把访问者的参数定为const T&。只有当操作确实需要修改对象,比如代码插桩修改 AST 节点,才使用非 const 版本。

提示:建议把Shape::Accept定义成两个版本,一个接受ShapeVisitor&,一个接受const ShapeVisitor&,并分别用Additional const约束。虽然代码量多一点,但能避免大量调用点上的 const 转换问题。

2.3 避免“上帝访问者”的结构性重构

访问者模式用歪的典型表现,是把所有业务逻辑都塞进一个超大类里。假如你写了一个ShapeAllInOneVisitor,里面有绘制逻辑、序列化逻辑、统计逻辑,每个 Visit 分支越写越长,这个类很快会变成新的巨石。所以我会刻意保持每个访问者的单一职责。一个访问者只做一件事:要么只负责渲染,要么只负责序列化,要么只负责面积统计。如果确实需要多个操作组合,就定义多个访问者对象在遍历函数里连续使用,而不是把功能堆进一个类。

还有一点容易被忽略:访问者模式的遍历骨架本身也可以单独提炼。很多项目里,遍历一棵树可能要前序、后序、带深度限制、带路径上下文等不同策略。这些策略属于“遍历骨架”,不应该塞进 Accept,也不应该每个 Visitor 各自实现一遍。更好的做法是单独写一个遍历函数,比如TraverseTree(Node* root, BaseVisitor& visitor),由它来控制递归顺序、栈管理、剪枝条件,Visitor 只需要接收单个节点的 Visit 回调。这样就把“怎么走”和“走到后干什么”彻底解耦,代码结构会清新很多。

3. 高级形态:用 std::variant 把访问者搬进值语义

3.1 从 std::visit 看访问者本质

C++17 引入 std::variant 后,访问者模式在值语义这边有了新的表达形态。以前访问者是建立在继承之上的,现在类型集合可以直接收拢成一个联合体:

using Shape = std::variant<Circle, Rectangle, Line>;

配对的分发现操作是std::visit:

Shape s = Circle{1.5}; std::visit([](const auto& item) { std::cout << "面积是 " << item.Area() << '\n'; }, s);

这套写法里没有基类指针,没有虚函数,也没有 Accept 方法。访问者的角色由可调用对象承担,类型的封闭性由 variant 本身保证。std::visit本质上是先把当前存的类型索引取出来,再通过一张跳转表或者编译器生成的 switch 定位到对应分支。它和虚函数的核心差异在于:虚函数用对象里的虚表指针找实现,而 variant 用索引找分支;前者允许“开放类型集合”,后者要求类型集合在编译期完全闭合。

很多团队说 std::visit 比访问者模式“更现代”,这个说法不算全对,但确实击中了痛点:所有分支必须明确写出,编译器会强制你处理每一种类型。少写一个Circle分支就会编译失败,这类错误是编译期就能暴露的最宝贵资产。而且 value 语义下的 variant 没有动态内存分配,也不存在对象切片问题,性能特征更可控。

3.2 用重载模式写出清爽的“多态 lambda”

std::visit 接受任何可调用对象,但单个 lambda 只能适配一组签名,遇到一个 variant 里类型不同、需要分别处理的场景就麻烦了。于是实践中出现了一个几乎人手一份的“重载模式”工具:

template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; }; template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;

这个模板把多个 lambda 合并成一个带多个operator()重载的函数对象。代码可以写成:

Shape s = Line{1.0, 2.0}; std::visit( Overloaded{ [](const Circle& c) { std::cout << "圆形,半径 " << c.radius << '\n'; }, [](const Rectangle& r) { std::cout << "矩形,宽高 " << r.w << 'x' << r.h << '\n'; }, [](const Line& l) { std::cout << "线段长度 " << l.Length() << '\n'; } }, s );

这种写法的可读性极好:每个类型一个分支,分支之间是并列关系,任何分支的增删都一目了然。C++20 之后一些编译器甚至支持把 Overloaded 定义拆到公共头文件,团队成员直接using common::Overloaded就能用。

要注意的是重载决议特别想搞你一手:如果两个 lambda 的参数一个写const ColorShape&,一个写const Circle&,而 ColorShape 恰好又是 Circle 的基类,那么当 variant 里装着 Circle 时,两个 lambda 都能匹配,编译器会认为二者都是可行的重载,于是直接报歧义。解决办法是不要写“参数是基类”的 lambda,坚持一个具体事件类型对应一个 lambda。真需要统一处理某个基类聚类时,可以显式转换或者使用模板 lambda 当兜底,但一定要清楚优先级规则。

3.3 无虚函数实现抽象业务逻辑的收益与代价

用 variant + visit 替代继承式访问者,最大的收益不是“少写几个虚函数”,而是让代码更容易被静态分析。类型集合在编译期是完整可见的,工具链也好、新人也好,打开一个 variant 定义就知道系统里能出现哪些对象;operator() 分支是否覆盖全类型也由编译器兜底,不存在“某个子类漏实现”的运行时炸弹。

代价则是类型集合必须固定。如果有人新增了一种 Shape,他必须修改 variant 类型本身,所有相关的 visit 调用点都要跟着编译一遍,很可能牵动几十处代码。在现实项目里,这种“牵一发动全身”未必是坏事,因为它强制你把类型变更的冲击面显式化。但如果产品形态还没稳定、类型经常新增,我建议还是回到经典访问者模式,或者直接保留抽象基类。

内存布局方面,variant 的大小按最大成员类型计算,不虚继承时通常没有隐藏指针开销,Cache 友好性明显好于shared_ptr<Shape>这种堆上多态对象。在游戏服务端、嵌入式 GUI 这种对分配敏感的系统里,variant 路线很有吸引力。但是也要注意,如果某个具体类型特别大,其他类型都会被拖累,需要考虑实际场景里的内存总预算。

4. 高级应用:真实场景里的工程实践

4.1 AST 遍历与源码分析工具

访问者模式最经典的落地场景之一就是抽象语法树。一个编译器前端里的 AST 节点动辄几十种:Literal、BinaryOp、IfStatement、WhileLoop、FunctionDecl……如果把遍历操作内联进节点,每种新分析都要给所有节点加成员函数。实际项目里更常见的是用访问者写分析器或转换器。

一个具体例子:想要统计源码里未使用的局部变量,传统做法可能需要在每个语法节点里塞逻辑,而基于访问者,只需实现一个UnusedVariableVisitor,在 Visit FunctionDecl 时维护作用域栈,在 Visit VariableDecl 时记录变量,在 Visit IdentifierRef 时标记引用。整个分析器只关心少数节点类型,其他节点通过默认实现直接跳过,代码量非常集中。

这里我必须强调一个工程细节:递归遍历 AST 时要注意栈深度。表达式遍历在前端经常出现几百层的嵌套表达式,递归函数一不小心就爆栈。我在项目里把遍历骨架单独抽出来,用显式栈替代递归,访问者拿到的还是同样的 Visit 回调,但底层已经不会因为输入文件深度而触发stack overflow。具体的显式栈实现并不复杂:每个堆栈元素要么是“待访节点”,要么是“出栈时需要触发的后序动作”,本质就是把递归栈手动搬到堆上。

4.2 事件分发与状态机里的另类应用

事件系统也是访问者的常客。假设有一套输入事件继承体系:MousePress、MouseMove、MouseRelease 都继承自 Event。传统做法会在 handler 里做一圈if (event.type == ...)判断,而用访问者后,每个 handler 就是一个 Visitor:

class InputHandler { public: virtual void Visit(const MousePress&) = 0; virtual void Visit(const MouseMove&) = 0; virtual void Visit(const MouseRelease&) = 0; };

新增一种命令事件,比如 KeyboardPress,就要给它也增加 Visit 接口。看似繁琐,但好处是所有 handler 强制同步,不会存在“某个 handler 忘了处理键盘事件”的情况。访问者在事件分发里的优势是静态类型安全:一个具体事件被断言成某个子类时,信息不会丢失,访问者天然握有完整类型信息。

状态机同样能用这套思想建模。状态类继承自 BaseState,每个状态提供 Accept 方法;迁移动作本身作为 Visitor,在 Visit 到当前状态时执行对应的进入动作、退出动作、迁移条件。这种设计在 C++ 游戏框架里出现过很多次,因为状态类型相对固定而状态迁移逻辑经常会变,访问者刚好契合“类型封闭、操作开放”的条件。我自己实验下来的体会是,状态数量很少时用访问者有点重,但状态超过十个且迁移逻辑复杂时,它比每次都写 switch 要清楚得多。

4.3 走向设计的选择:用访问者还是用模板?

有人会在这个环节提出一个尖锐问题:既然访问者的目标之一是把类型信息显式化,为什么不直接用模板在编译期完成所有分派?答案是你不能一直停留在编译期。经典访问者存在于运行期多态场景:你需要统一存容器为vector<unique_ptr<Shape>>、从外部接口接受一个基类引用,然后在某个具体操作点恢复具体的动态类型。这种“存的时候忘了类型,取的时候要恢复类型”的场景,模板帮不上忙。

如果类型集合很小,而且你完全接受值语义,那 variant 就像是一个更好的“模板代替品”。如果类型集合本来就在于运行期才决定,比如插件机制加载不同类型,那么你只能依赖虚函数 + 访问者的组合。还有一种折中路径:把访问者做成模板基类,用 CRTP 静态化部分逻辑,保留少量虚函数只做入口分派。代码可读性会差一些,但性能上确实能省掉一层虚调用。我在做编辑器插件系统时用过这个思路,但最终发现维护成本高,不到万不得已不建议团队里所有人都去尝试,收益可能抵不回可读性损失。

5. 性能、代码体积和“坑”实操笔记

5.1 虚函数调用与 std::visit 的性能差异

访问者模式的经典版本一次完整分发通常会发生两次间接跳转:先调 Accept(虚函数),再调 Visit(虚函数)。std::visit 大多是生成一张以类型索引为下标的函数指针表,然后按当前索引取值跳转,很多实现还会配合 switch 优化,让编译器有机会做 devirtualization。更方便的是,因为 std::visit 接收的是编译期可见的可调用对象,lambda 体通常能被内联,这对热路径优化非常有利。所以单看分派开销,std::visit 在多数实现里不比访问者模式差,甚至经常更快。

但不要盲目相信“std::visit 一定更快”这类结论。如果 variant 里只有一个类型,编译器可能直接展开成一个普通调用,比过程化调用还快;反过来如果类型数量有几十个,跳转表占的指令缓存会很明显,而虚函数调用则轻量得多。实际测量时还要注意编译器版本、优化级别、是否 LTO 等因素。如果你正在做高频场景比如物理引擎里的碰撞分类,我的建议是先写一个能代表真实分布的小 benchmark,而不是凭网上的结论拍脑袋。另一点常常被忽略:std::visit 在 Debug 构建下往往比虚函数慢得多,因为它依赖编译器生成大量 switch 跳转逻辑,Debug 模式下这些代码不太友好。

5.2 代码增量与可写性权衡

访问者模式最大的代价始终是样板代码,这一点谁都没法否认。经典写法揉进一个多类型项目,每个类型要写 Accept,每个访问者要写若干 Visit 方法,几十个类型跑下来代码量非常可观。要缓解样板感,可以把 Accept 方法做成宏或生成工具,比如在基类中用 CRTP 自动生成,或者用脚本从类型清单里生成接口文件。我个人不太推荐为了这点代码就去引元编程框架,但用 CMake 加一个代码生成步骤是可行的。

另一个深层的权衡是“可写性”问题:当一个访问者只需要处理 100 个类型中的 3 个时,让那 97 个都走默认实现会让新增分支变得极难发现。更好的做法是让默认实现不是空着,而是记录警告或抛异常。这样开发期一旦访问到未处理类型,你立刻能在测试里发现遗漏,而不是让 bug 在发布后才冒出来。

5.3 常见编译报错与排查记录

我在项目里收集过一组高频问题,整理成速查表,遇到可直接对照:

报错或现象常见原因处理办法
no matching function for call to Visit(...)访问者只写了 const& 版本,调用方传入非 const 对象补一个非 const 重载,或者在调用处统一走 const 路径
call of overloaded Visit(...) is ambiguous多个重载可匹配,常见于模板重载和具体重载并存去掉模板重载,或改用 tag dispatch 显式选择
cannot convert shared_ptr to const Element&访问者接收引用,但调用处用智能指针变量直接匹配遍历时先解引用*p再传给 Accept,不要动访问者签名
class does not implement all pure virtual Visit 函数接口新增了某个具体类型,但已有访问者没同步要么补实现,要么把接口方法从纯虚改成带默认实现的虚函数
运行期走了默认分支、逻辑静默错误默认兜底实现没用断言标记“未处理”状态默认实现里放 assert,或记录错误日志,让问题尽快暴露

在实际排查中我发现,访问者模式的大多数诡异 bug 都源自重载决议和模板的纠缠。比如在 Visitor 里写了一个模板成员函数template<typename T> void Visit(const T&),本来想让模板兜底所有类型,结果具体的重载和模板重载同时存在时,精确定匹配通常优先,但一旦参数类型涉及派生类转换,结果可能出乎意料。为了省心,我会避免在经典访问者接口里混入模板重载,只在 std::visit 的分支里才使用泛型 lambda。泛型分支如果和具体分支冲突,编译器也会第一时间指出来,比运行时错误好查得多。

最后一个隐蔽问题来自类继承的菱形结构。如果 Circle 同时继承自 Shape 和 Renderable,而 Renderable 里也有自己的 Accept,那么 Circle 的 Accept 实现里必须明确写清楚调用的是哪个基类的逻辑,否则编译器在重载决议时可能突然看到两条候选路径。遇到这种对象模型本来就复杂的类,访问者未必是最佳选择,先把继承结构简化才是正解。

访问者模式在 C++ 里是一门越用越有味道的技巧。它不像单例、工厂那样一学就能立刻塞进项目,需要你先判断“类型是否封闭、操作是否开放”这个前提。在我的代码库里,现在更多是拿 std::variant + std::visit 处理那些类型稳定的数据模型,而经典访问者留给真正的多态对象树。两种写法都值得掌握,因为它们共用同一套思维:把分派和逻辑拆开,让类型信息在编译期清清楚楚。个人经验是,团队刚开始引入访问者时,一定要先在核心接口上把默认实现、const 正确性和编译期强制力这三件事想明白,否则后续每次扩展类型都可能踩到同一个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询