聊到 C++ 里的访问者模式,我第一反应不是那本 GoF 书上的类图,而是上个月我重构 DSL 求值器时拆掉的一堆 accept 函数。当时树上有七八种节点,每种节点都要写 accept,每个新需求还得去所有 visitor 实现里补分支。改着改着你会发现,真正让访问者模式在 C++ 里变得好用的,早就不再是教科书上那套双分派模板了,而是围绕“访问”这件事长出来的一堆变体:std::variant 加 std::visit 的组合、lambda 重载、CRTP 做默认路由、类型擦除封装第三方对象等等。
这篇文章想把我在实际项目里筛过的这些变体一次性讲透。你会看到同一个语言特性在不同约束下怎么演化出不同写法,也会看到 std::visit 并不是万灵药,它有自己的递归陷阱、编译失败场景和性能边界。适合正在用 C++17/C++20 写 AST、解释器、代码生成器、UI 消息分发这类“类型集合固定但操作层出不穷”的朋友参考。
1. 访问者模式的核心价值与 “变体” 的含义
1.1 双分派:为什么 C++ 需要访问者
先回到最初的问题:访问者模式解决的是“为一个相对固定的类型集合,扩展大量不相关操作”的诉求。
比如你有一棵表达式树,里面只有 Literal、Binary、Unary 三类节点。需求方今天要你写求值器,明天要你生成代码,后天要你打印 AST,大后天又要做类型检查。如果把每个操作都写成节点基类的虚函数,那么每增加一个操作,就得往每个节点类里塞一个新方法,节点类会变得越来越臃肿,第三方想扩展操作也根本伸不进手。
访问者的思路是把操作拿出来,做成一个独立对象。节点类本身不再关心“我能算什么”,只关心“我接收谁的访问”。于是新增操作时不需要动节点类,只要新增一个访问者实现就行。
但 C++ 的虚函数只有单分派:调用 v.func(obj) 时,哪个 func 实现被选中,只由 obj 的动态类型决定,决定不了调用者的动态类型。访问者模式要的是双分派,既要区分节点类型,又要区分访问者类型,这才有了最经典的那套 accept + visit 结构:
cpp复制struct AudioNode;
struct VideoNode;
struct MediaVisitor {
virtual ~MediaVisitor() = default;
virtual void visit(const AudioNode& node) = 0;
virtual void visit(const VideoNode& node) = 0;
};
struct MediaNode {
virtual ~MediaNode() = default;
virtual void accept(MediaVisitor& v) = 0;
};
struct AudioNode final : MediaNode {
void accept(MediaVisitor& v) override {
v.visit(*this);
}
};
struct VideoNode final : MediaNode {
void accept(MediaVisitor& v) override {
v.visit(*this);
}
};
第一次分派发生在调用节点的 accept 时,虚函数机制根据节点对象选出正确的 accept。第二次分派发生在 accept 内部调用 v.visit(*this) 时,由于 *this 的静态类型在 audio 节点里就是 AudioNode&,编译器直接挑选 visit 的重载版本。两个维度都在编译期或运行期被正确锁定,这就是双分派的本质。
这个版本今天仍然能用,尤其适合“对象是别人容器里的多态指针,你无法改变存储结构,只能从一个基类指针入手”的场景。我在老项目里见过不少这么写代码的,稳定,可读,不开任何历史倒车。
1.2 “变体”到底在变什么
C++ 里的访问者模式之所以会冒出那么多变体,核心原因是 C++ 本身提供了好几套表达“类型集合”的手段。
传统面向对象用继承体系表达类型集合,所以访问者必须借用虚函数来解决动态分发。但 C++17 之后,类型集合还可以用一个封闭的 sum type 来表达,也就是 std::variant<T1, T2, T3>。sum type 本身就是“当前对象一定是这些类型中的某一个”的编译期描述。配合 std::visit,你可以在零虚函数、零继承的前提下完成同样的访问逻辑。
所以“变体”这个词,我理解不是某种具体技术,而是访问者思想在 C++ 类型系统下呈现出的方案家族。本质上变的是三样东西:
- 类型集合的组织方式:继承体系还是
std::variant还是类型擦除容器。 - 分发时机:运行期 vtable 查找,还是编译期根据 index 分发表生成 switch。
- 访问操作的组合方式:虚函数 override、函数对象重载、还是 lambda 包展开。
把这三条线想清楚,后面看任何 visitor 代码都不会发懵。实际选型时也不必拘泥于某种“正统”,哪个维度让当前项目的类型扩展和维护成本更低,就选哪个。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典多态访问者:原版的边界和代价
2.1 教科书版本的真实可用性
先别急着批判经典写法。很多场景下,经典访问者依然是唯一合理选择。最典型的是:你已经有一个完整的多态对象图,对象以 std::shared_ptr<MediaNode> 或裸指针存储,使用者手里只有 MediaNode&,根本不知道也不关心真实类型。
我接手过一个音频视频资源管理模块,MediaNode 下面挂了几十个具体类型,Container 节点里还有子节点列表。想统一做资源回收、缓存清理和统计三件事时,第一直觉就是套访问者模式。每个具体节点实现 accept,三个 visitor 只处理自己关心的类型,不关心的节点通过基类的空实现忽略掉。运行期虚拟分发不需要在调用处堆 if (type == ...),扩充新节点时也因为基类默认空实现了,不至于让所有 visitor 瞬间编译失败。
这种用法的本质是:你利用虚函数表保证了“节点内存布局对调用方透明”。节点怎么创建、怎么释放,外部 visitor 全都不关心,只遵循一个稳定的基类接口。
不过它要付出的代价也需要如实说明。第一,访问者接口 MediaVisitor 必须提前枚举可能出现的所有节点类型,新增一种底层节点时,理论上最好在接口里增加一个纯虚 visit 重载。如果不是纯虚,而是基类给空实现,那么漏掉一个类型时不会编译报错,新增操作会静默漏处理该类型,这种 bug 比编译错误阴险得多。第二,如果节点层级不是扁平而是深层嵌套,每一层都要复写 accept 转发逻辑,代码重复度很高。第三,每次调用 accept 时至少要经历两层虚函数跳转,间接性和缓存不友好是客观存在的。
2.2 经典写法的三个致命伤
我实际踩过的坑主要有三处,写出来给各位抄作业时避雷。
第一个是新增节点的“连锁地震”。如果 MediaVisitor 里定义了纯虚 visit,那么每增加一个节点类型,所有已存在的 visitor 都得跟着增加实现;哪怕这个 visitor 根本不关心新节点,也得补一个空函数体。项目里 visitor 一多,一次节点扩展要同步改动十来个文件,非常劝退。
第二个是它对第三方类型无效。你的节点类能实现 accept,是因为它的源码握在自己手里。如果对方库提供了一个和你业务强相关的类型,比如某种图片格式的句柄类,你没法往它里面塞 accept 函数,经典访问者就断在这里。这时常见的补救是用适配器包一层,拿包装类去实现 accept,再把实际对象传给 visit。这个方案可行,但多一层的样板代码不少。
第三个是空操作分支容易悄悄写成 bug。我给统计 visitor 写“不关心”分支时,心里想的是反正默认啥都不干,结果后来排查内存回收问题时才发现某个类型的资源没被释放,因为我在一个新 visitor 里忘记处理它,编译器也没给任何提示。
所以要给经典版本下一个精准结论:它适合节点数量常年稳定、但操作经常新增、且允许空操作静默存在的业务。一旦节点类型本身开始频繁膨胀,这套模式的维护成本会直线上升,就有必要看看基于 std::variant 的现代版本了。
3. std::variant 的现代访问者变体
3.1 把多态对象图改造成编译期封闭类型
现代写法第一步是改变类型表达方式。同样一个表达式树,不再用抽象基类加子类,而是先把每个节点类型定义成普通结构体,再用 std::variant 把所有可能的类型注册到一起:
cpp复制struct Node;
using NodePtr = std::shared_ptr<Node>;
struct Number {
double value;
};
struct AddExpr {
NodePtr left;
NodePtr right;
};
struct Node {
using Rep = std::variant<Number, AddExpr>;
Rep rep;
Node(Number v) : rep(std::move(v)) {}
Node(AddExpr v) : rep(std::move(v)) {}
};
注意这里我特意没有让 Node 直接继承 std::variant<Number, AddExpr>。递归 variant 在 C++ 里比较麻烦,因为 variant 的模板参数需要完整类型,而如果 AddExpr 里直接写 Node left 就会产生无限递归,连 sizeof 都无法确定。让 AddExpr 只持有一个 std::shared_ptr<Node>,就把指针指向不完全类型的问题解决了;再把 variant 包进 Node 结构体作为成员,构造时隐式转换,访问时直接 node.rep 传给 std::visit,思路非常清晰。
从虚基类体系切到 variant 的第一个好处是:类型集合现在是显式的、封闭的、编译期可见的。你在代码里能直接看到“节点只可能有 Number 或 AddExpr 两种”。不会出现有人偷偷从一个抽象 Node 又继承一个 MulNode,然后某 visitor 漏处理的情况。如果你的新增类型确实可能存在,那就主动把它写进 using 别名里,所有访问者都会在编译期受到约束,藏不住。
3.2 std::visit 与 lambda 重载:访问者的新时代
有了 variant 后的访问逻辑,最自然的是用 std::visit。它接收两个参数:一个可调用对象和一个 variant。运行时 variant 内部存的是哪个类型,std::visit 会分派给可调用对象的对应重载。
在老式语法里,可调用对象可以写成普通结构体:
cpp复制struct Eval {
double operator()(const Number& num) const {
return num.value;
}
double operator()(const AddExpr& add) const {
double left = std::visit(*this, add.left->rep);
double right = std::visit(*this, add.right->rep);
return left + right;
}
};
double result = std::visit(Eval{}, root->rep);
可如果你只是做一个临时遍历,为一个临时遍历专门定义一个类未免有些重。C++17 实际项目里更常见的是配合 lambda 重载集,把一个一个分支的 lambda 拼成一个 visitor:
cpp复制template <typename... Ts>
struct Overloaded : Ts... {
using Ts::operator()...;
};
template <typename... Ts>
Overloaded(Ts...) -> Overloaded<Ts...>;
auto printer = Overloaded{
[](const Number& num) { fmt::print("num {}", num.value); },
[](const AddExpr& add) { fmt::print("add"); },
};
std::visit(printer, node.rep);
这个 Overloaded 的技巧本质是让多个 lambda 通过多重继承合并成同一个类型,再用 using Ts::operator()... 把基类里各自的调用运算符引入同一作用域。C++17 的类模板参数推导负责从初始化列表自动生成 Overloaded 的模板参数,所以写完 Overloaded{...} 就能直接当 visitor 用。
如果你觉得每次写这个模板啰嗦,可以把它放到公共头文件里。如果项目已经用 C++20,也有人用 std::visit 加泛型 lambda 加 if constexpr 来写,这种写法适合分支少且类型处理差异很小的场景;分支逻辑差异明显时,我更推荐显式重载,代码直白,报错也更友好。只能说 select, not holy.
3.3 递归访问者:循环、终止和状态管理
用 std::visit 访问树时很容易写出上面 Eval 里的递归结构。这里有个细节很多人第一次会疑惑:std::visit(*this, add.left->rep) 里的 *this 是怎么传进去的,会不会把整个 visitor 复制一遍?
从标准语义来看,std::visit 对 visitor 参数是转发的,现代标准库实现不会复制整个 visitor,所以不用担心大对象被层层拷贝。但如果 visitor 里放了 std::unordered_map 这类带状态的东西,operator() 又声明成 const,就没法在遍历中修改它。遇到求值器带符号表的情况,我通常把可变状态包一个 shared_ptr,让多个递归分支共享同一份上下文:
cpp复制struct SymbolTable {
std::unordered_map<std::string, double> values;
};
struct Eval {
std::shared_ptr<SymbolTable> symbols;
double operator()(const Number& num) const {
return num.value;
}
double operator()(const AddExpr& add) const {
// 即便 *this 被以任何方式拷贝,shared_ptr 仍指向同一张表
double left = std::visit(*this, add.left->rep);
double right = std::visit(*this, add.right->rep);
return left + right;
}
double operator()(const Identifier& id) const {
return symbols->values.at(id.name);
}
};
递归遍历树的另一个隐患是深度过深导致栈溢出。访问者只是把遍历逻辑写得更好看,并不会消除递归的本质。你处理一段从文件解析出来的超深数学表达式时,如果嵌套深度到了上万层,无论用经典 visitor 还是 std::visit 递归都会爆栈。真要处理这种东西,得把递归改成显式栈遍历,或者提高线程栈大小,别在 visitor 设计上纠结。
多提一句:如果表达式树里还有带子节点的类型,递归调用时要注意访问顺序。比如求值 AddExpr 要先算左子树,再算右子树;打印 AST 时要带着缩进参数往下传。访问者结构体里的 operator() 应该接收节点作为参数,并在内部决定是否递归访问子节点。这和传统递归下降一样,只是从“访问当前类型的分支”变成了“对当前类型调用对应 visitor 重载”。
3.4 编译期分发不是没有代价
我最初把经典访问者改成 std::visit,是一拍脑袋冲着“性能更好”去的,后来 benchmark 才纠正了认知。
std::visit 的实现通常会在编译期为每个 variant 建立一个 switch 分发表,然后在运行期读 variant 的 index 跳到对应分支。如果 variant 中的类型是简单的 POD,编译器有机会把整个访问过程内联,从而比虚函数还快。但如果节点类型很多、且每个分支里又递归调用多层 std::visit,生成的代码量会很可观,编译时间和最终二进制体积都会上升。在一些老版本 MSVC 上,我曾经遇到过 std::visit 的性能反而不如手写 switch;换到 GCC 后差异又不一样。
所以实操建议是:先把代码写清楚,再用基准测试说话。对一般业务代码,std::visit 的清晰度收益远大于性能微调成本。只有当你明确知道这个遍历处在高频热路径,并且 profile 显示它真的是瓶颈时,才值得降级成手写的 get_if 多分支实现。访问者模式变体的意义不是让你炫技,而是在正确的位置降低结构复杂度。
4. 几个更实用的访问者模式变体思路
4.1 CRTP 默认路由:缓解“新增节点要改所有 visitor”的冲击
经典版本最大的维护痛点是新增节点时所有 visitor 都要动。与其每次改十个文件,不如让 visitor 基类通过 CRTP 持有派生 visitor 的静态类型,给每个 visit 重载一个默认实现:
cpp复制template <typename Derived>
struct DefaultVisitor {
void visit(const AudioNode& node) {
static_cast<Derived*>(this)->defaultVisit(node);
}
void visit(const VideoNode& node) {
static_cast<Derived*>(this)->defaultVisit(node);
}
private:
void defaultVisit(const AudioNode&) {}
void defaultVisit(const VideoNode&) {}
};
struct PlaybackVisitor : DefaultVisitor<PlaybackVisitor> {
void visit(const AudioNode& node) {
// 真正的播放逻辑
}
};
这里的诀窍是:如果派生 visitor 定义的 operator/visit 签名和基类重载同名,派生类会隐藏基类版本;而遍历外部统一通过某个引用了 Derived 的入口调用,实际会命中派生类实现。没覆盖的类型自动落到基类空操作,不会让所有现有 visitor 因新增类型而编译失败。
但 CRTP 默认路由不是银弹。它引入的静态继承关系复杂,而且 debug 时模板展开让调用栈很不好读。如果项目里 visitor 数量不多,或者你希望“漏掉类型时直接编译报错”来保护逻辑,CRTP 默认路由反而会掩盖错误。所以它更适合“新增节点频率高,但大部分 visitor 对大多数节点都没兴趣”的中间场景。
4.2 类型擦除访问者:把外部类型拉进自己的访问体系
很多人忽略了访问者模式用于“第三方封闭类型”时的扩展价值。假如你的节点结构里要处理某个外部图形库的自定义形状,你又改不了它的类定义,这时候可以做一个适配器节点,把外部类型装进自己的 variant 体系:
cpp复制struct ExternalCircle {
double radius;
};
struct ExternalRect {
double width;
double height;
};
struct ShapeNode {
std::variant<ExternalCircle, ExternalRect> geom;
};
// 在访问逻辑中统一处理
auto area = std::visit(Overloaded{
[](const ExternalCircle& c) { return 3.1415926 * c.radius * c.radius; },
[](const ExternalRect& r) { return r.width * r.height; },
}, shapeNode.geom);
如果项目里必须用多态基类,也可以定义包装类,让它继承你的访问者接口,同时持有第三方对象引用。包装类里的 accept 转发给访问者的 visit 重载时,第三方对象就“混”进了访问者体系。这个方式不用改外部库,只增加了少量适配代码,比到处 dynamic_cast 干净得多。
说句实在的,访问者模式的一个高级价值,就是不让“我没有这个类源码”成为编写稳定遍历代码的障碍。类型擦除和适配器是最后一道保险,顺手就做掉了。
4.3 混合方案:用节点类型枚举处理多态继承对象树
很多老代码不能改成 std::variant,因为对象图已经大量使用了继承和多态,甚至有人已经把对象序列化到库里了。一套保险的做法是给每个节点类提供一个虚函数 nodeType(),返回类型枚举,然后写一个访问器内部通过 switch 把节点分发给 lambda 处理:
cpp复制enum class NodeType {
Audio,
Video,
};
NodeType type = node->nodeType();
switch (type) {
case NodeType::Audio: {
AudioNode* audio = static_cast<AudioNode*>(node.get());
// 处理 audio
break;
}
case NodeType::Video:
// ...
break;
}
这一版其实就是“模拟 variant”的手写 type tag 分发,不要求类型集合封闭,也不需要 accept 的二次转发。它比对每个子类写 accept 少一层虚函数,多了一些 static_cast 风险。实际项目里如果节点类型较少、类型集合又稳定,手写 switch 会比 std::visit 更直白,性能也可预测。但类型一旦膨胀、多个 caller 都要写这类 switch,就会产生大量重复分支,可维护性马上崩盘。
把这些变体放在一起看,它们不是竞争关系,而是适用于不同边界的解决方案。类型集合小、调用点少用 switch;类型集合固定、操作多且追求编译期反馈用 std::visit;类型集合开放且操作多、但外部要求稳定接口用经典 visitor;希望减少新增节点对历史 visitor 的扩散影响用 CRTP 默认路由。选型看约束,不看出身。
5. 实际项目选型:结合版本、场景和工程约束
5.1 从“加法操作”和“乘法操作”两个视角做决策
判断系统中最常变化的是什么,是选型第一原则。这个原则用在访问者模式上非常直观。
如果你发现业务里经常要新增节点类型,比如一个月加两个表达式节点,说明“类型维度”是变化的重点。这时最好用继承体系加虚函数,因为加一个子类时只需要实现这个类的虚函数,不必去改历史 visitor。如果硬用 std::variant,每新增一个类型都要同步修改 using 别名,还要让所有访问者补 operator(),改动面会大得多。
反过来,如果节点类型常年不变,而是需求里不断冒出“我要新增一种遍历/操作”,比如先做打印、再做求值、再做类型检查,那么 std::variant 是绝对的优选。新增操作只需要写一个新的 visitor 结构体或 lambda 重载集,节点类型定义完全不用碰。经典访问者也支持新增操作,只是要多维护一层 accept 结构和接口之间的依赖。
还有一个容易忽略的工程点是头文件依赖。variant 方案让所有节点类型集中在同一个 using 别名里,很多 visitor 可以放在单独的头文件,避免每次加新操作都重新编译整棵 AST 的相关代码。这一点在大型项目里节省的时间,往往比模式层面差异更明显。
5.2 不同的 C++ 标准版本怎么兼容
std::variant 要 C++17 才能用。如果你还在维护 C++11/C++14 的老项目,而且不想引入 Boost,最合理的还是经典多态访问者。C++14 下如果想写出更接近编译期分发的 visitor,可以用模板加 tag dispatch,但复杂度会明显提升,收益不稳定,一般不至于为此折腾。
如果项目里已经在用 Boost,那么 C++14 下用 boost::variant 加 boost::apply_visitor 也可以获得和 std::visit 几乎一致的体验。它支持的类型数量、递归变体处理方式都和标准库版本有细微差异,需要额外看文档。引入 Boost 本身是个工程决策,如果团队对依赖体积有要求,还是掂量一下。
对 C++20 用户,除了 std::visit,你还可以使用带显式模板参数的重载方案,配合 if constexpr 写统一处理逻辑。这种风格比较适合分支差异集中在少量类型上的场景,比如每个节点的处理都需要先打印节点名称,后续行为才分叉。如果所有分支差异都很大,还是乖乖拆分重载,否则一个 Operator() 里塞大量 if constexpr 会很难读。
至于递归变体的构造问题,我在第三章已经演示过包装方案。真正要注意的是:不要把这种包装误认为是“访问者模式的额外复杂度”。递归 sum type 在 Haskell 里也需要用函数类型或列表指针包一层。C++ 只是让你显式地写出 NodePtr 这个间接层,这是一笔值得付的账。
6. 常见问题与排查技巧实录
6.1 编译失败一:std::visit 找不到匹配的 operator()
最常见的问题,是在 std::visit 里传入的 visitor 没有覆盖 variant 中的所有类型。AddExpr 类型扩成了三个节点,但 visitor 只写了 Number 和 AddExpr 两个重载,LLVM 会报一段很长的模板错误,大意是和 std::variant 的某个候选不匹配。
排查顺序是:先看 variant 类型别名里到底有哪些类型,再检查 visitor 里是否给每个类型都写了可调用重载。如果分支里用了重载 lambda,还要确认 lambda 参数类型是否精确匹配,比如参数写 Number& 而 variant 里存的是 const Number,会因为 const 限定不匹配而编译不过。很多人在泛型 lambda 里写 auto& node,再用 if constexpr 细分类型,这种写法能绕开部分问题,但分支逻辑混杂时降低了阅读性。我自己的偏好是每个类型显式写重载,编译失败时定位更快。
6.2 运行期崩溃:bad_variant_access 和 valueless
std::variant 在赋值过程中如果发生了异常,可能出现 valueless_by_exception 状态。此时 std::visit 会抛出什么异常,我记不太准,但反正不要抱着“variant 一定有个合法值”的侥幸。如果你在项目里用 variant 内置很多带资源句柄的结构体,建议给每个 variant 的构造、赋值做足够的异常安全测试。
另外,当你在 variant 中显式放入 std::monostate 表示空状态时,所有 visitor 都要处理 monostate 分支。这个分支不是漏洞,而是一种良好的显式设计:解析失败、节点缺失、初始化未完成都可以用它表达。任何可能的逻辑入口,访问前最好先判断:
cpp复制if (node.rep.valueless_by_exception()) {
// 处理非法状态
}
