有个场景我印象特别深。项目里从Excel导入了一堆操作节点,解析完之后会生成好几种不同类型的业务对象,后面每个模块都要对着这些对象做不同的处理——校验、入库、转存、画界面。一开始就是 switch + if-else,类型一多代码堆得没法看。当时几乎所有人给我的建议都是“用访问者模式啊”,结果在 C++ 里真用起来,处处别扭。后来我把项目迁到 C++17,改用了 std::variant 这套实现,代码量直接砍半,还顺手干掉了一批虚函数。这篇就聊聊我在实际工程里怎么用这种访问者模式的变体,以及哪些场景下我仍然会老老实实写经典 Visitor。
1. 教科书里的经典访问者模式,为什么在 C++ 里总有点别扭
1.1 先摆一个最朴素的经典实现
访问者模式的核心目标是“把操作从数据结构里抽出来”。比如有一组图形,要给它们加求面积、序列化、绘制这些操作,不希望每个图形类里各放一套方法,于是把操作集中到 Visitor 里。
看一个最简单的例子:
cpp复制class Circle;
class Rect;
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(const Circle&) = 0;
virtual void visit(const Rect&) = 0;
};
class Shape {
public:
virtual ~Shape() = default;
virtual void accept(Visitor&) const = 0;
};
class Circle : public Shape {
public:
void accept(Visitor& v) const override { v.visit(*this); }
double radius = 0.0;
};
class Rect : public Shape {
public:
void accept(Visitor& v) const override { v.visit(*this); }
double width = 0.0;
double height = 0.0;
};
然后做一个求面积的操作:
cpp复制class AreaVisitor : public Visitor {
public:
double result = 0.0;
void visit(const Circle& c) override {
result = 3.1415926 * c.radius * c.radius;
}
void visit(const Rect& r) override {
result = r.width * r.height;
}
};
调用端先构建对象,再让对象 accept 一个访问者:
cpp复制std::unique_ptr<Shape> s = std::make_unique<Circle>();
AreaVisitor av;
s->accept(av);
这套代码在技能点上没有任何问题,double dispatch 也确实起到了作用——运行时根据 Shape 的真实类型,再结合 Visitor 的具体类型,锁定了 visit 函数。但我自己在工程里用到第三个访问者时,就开始有点烦了。
1.2 经典实现的三个尴尬
第一个尴尬是样板代码。每个 Shape 派生类都要写一遍 accept,每个 Visitor 都要把所有 visit 纯虚函数实现一遍。如果你只想写一个“求面积”的访问者,也得把 visit(Circle)、visit(Rect) 全部摆齐,哪怕 Rect 在面积计算里暂时用不到任何定制逻辑。类型一多,访问者类会变得很长,而里面大量是无意义的空实现。
第二个尴尬更致命:新增类型会让整个体系“地震”。假设产品经理说再加一个 Triangle:
Visitor基类必须新增virtual void visit(const Triangle&);- 所有已经写好的 AreaVisitor、SerializeVisitor、DrawVisitor 全部编译报错;
- 你不得不打开每个 visitor 文件,补一个新分支。
这跟开闭原则完全是反着来的。经典访问者模式把“数据结构扩展”的代价转移到了“操作侧”,但实际业务里,类型扩展和操作扩展都经常发生,两边都不好受。
第三个尴尬是头文件依赖。Shape 要前置声明 Visitor,Visitor 又要前置声明 Circle、Rect,具体类头文件一不小心就循环包含。工程上为了理顺这些依赖,经常要加一层抽象,或者干脆把 Visitor 接口暴露到一个专门的头部,维护成本一直在涨。
1.3 所以我看待“访问者模式变体”的角度
后来我逐渐意识到,经典 Visitor 的价值在于“运行时多态 + 双重分派”,而 C++ 世界里,std::variant 本身就是一种编译期可穷尽的“类型集合”。用 variant 承载类型集合,用 std::visit 做分派,等于把经典 visitor 从“虚函数传参”换成了“编译期构建的跳转逻辑”。它保留了模式最核心的思想——把操作和数据分离、按类型分派,同时绕开了虚函数体系的一堆毛病。
这也是我标题里说的“变体”的含义:不是某个第三方库里的神秘新特性,而是用 C++17 之后的标准库特性,把访问者模式重新实现了一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. std::variant 与 std::visit:访问者模式的现代变体
2.1 用值和编译期类型集合替代继承体系
std::variant 在 C++17 引入,可以理解为“类型安全的 union”。它的占位空间能容纳类型列表中的任意一个对象,而且知道当前到底持有的是哪一种类型。与 union 不同的是,variant 不会放任你把 double 当整数读,所有类型访问都要经过 std::visit 或 get_if。
用 variant 重新实现上面的图形例子,可以彻底扔掉继承:
cpp复制struct Circle {
double radius = 0.0;
};
struct Rect {
double width = 0.0;
double height = 0.0;
};
using Shape = std::variant<Circle, Rect>;
先别急着说“这不算访问者模式”,往下看操作侧:求面积直接对 variant 做 visit。
cpp复制double area(const Shape& s) {
return std::visit(overloaded{
[](const Circle& c) { return 3.1415926 * c.radius * c.radius; },
[](const Rect& r) { return r.width * r.height; }
}, s);
}
这里 overloaded 是一个很好用的小工具,作用是把若干个 lambda 打包成一个对象。它在工程里被我复制粘贴了无数次:
cpp复制template<typename... Ts>
struct overloaded : Ts... {
using Ts::operator()...;
};
template<typename... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
C++17 里需要配合这个推导指引才能直接用花括号构造;到了 C++20,类模板参数推导已经把这种东西融得很自然了,所以我一般干脆定义一个别名:
cpp复制template<typename... Ts>
overloaded(Ts...) -> overloaded<Ts...>;
template<typename... Ts>
using visitor_t = overloaded<Ts...>;
调用端变成:
cpp复制Shape s = Circle{2.0};
double result = area(s);
没有 new,没有 unique_ptr,没有 vptr,没有 accept 样板,Circle 和 Rect 都是最普通的结构体。这就是 std::variant 带来的第一个变化:值语义。
2.2 overloaded 为什么能工作
要理解这套写法,绕不开 overloaded 的原理。lambda 本质上是一个匿名类,operator() 就是它的调用入口。我把两个 lambda 合并进一个结构体里,再通过 using Ts::operator()... 把所有基类的调用运算符提升到同一个作用域中,这样 overloaded 这个对象对外就同时具备了多个 operator() 重载。
std::visit 遍历 variant 的候选类型时,会对当前实际持有的类型做一次重载决议,找到唯一匹配的那个 lambda。哪个 lambda 最匹配当前类型,就执行哪个。这个调度过程在编译期展开,运行时的开销理论上就只是一次跳转。
这种写法的核心收益在于:每个 lambda 是独立的小函数,局部性极好,不相关的类型处理逻辑彼此隔离。经典 visitor 把“一个操作遍历所有类型”强制集中在一个类里,而你把每个操作拆成独立分支,反而更好维护。
有人会担心“lambda 不能做虚函数覆盖”,确实不用做,visit 根本不需要虚函数。
2.3 新增类型的体验完全不同
回到 1.2 那个产品经理要加 Triangle 的场景,在 variant 版本里流程是这样的:
- 定义
struct Triangle { double base, height; }; - 在
using Shape = std::variant<Circle, Rect, Triangle>;中加上 Triangle。 - 编译,然后编译器会告诉你哪些地方
std::visit没有处理 Triangle,一个个补分支即可。
经典版本是“接口改了之后,所有 visitor 编译报错”,variant 版本是“类型列表改了之后,所有 visit 调用点编译报错”。两者都会报错,但 variant 版本的报错更精准:直接定位到具体使用处,而不是跑到 visitor 的纯虚函数声明里。
更关键的是,假设新类型只出现在一个角落,variant 不会强迫你把所有操作都补齐。你暂时不想处理 Triangle 也没有关系,编译器不会在运行期偷偷用一个空操作,只要你调用到 area,就得给出分支。
3. 单 visitor 的 if constexpr 变体:适合什么场景
3.1 泛型 lambda 与编译期类型分支
overloaded 适合“每个类型的处理逻辑相对独立”的情况。但如果每个分支里有很多共享骨架,比如每种节点都要序列化成 XML,只是字段名和取值方式不同,那写一堆 lambda 就显得重复。
这时可以换一种变体写法:用泛型 lambda 接住任意类型,再用 if constexpr 做编译期分支。
cpp复制std::string dumpToXml(const Shape& s) {
return std::visit([](const auto& shape) -> std::string {
using T = std::decay_t<decltype(shape)>;
if constexpr (std::is_same_v<T, Circle>) {
return "<circle radius=\"" + std::to_string(shape.radius) + "\"/>";
} else if constexpr (std::is_same_v<T, Rect>) {
return "<rect width=\"" + std::to_string(shape.width) + "\"/>";
} else {
static_assert(always_false<T>, "unhandled shape type");
}
}, s);
}
这段代码里,decltype(shape) 在泛型 lambda 中拿到的是 const Circle& 或 const Rect&,所以先 decay_t 去掉引用和 cv 限定的类型,再做类型比较。if constexpr 会在编译期丢弃不匹配的分支,只保留当前类型对应的那段代码。
C++17 里写这个 static_assert 依赖一个技巧。直接写 static_assert(false, "...") 不行,因为模板实例化前整个分支就会被诊断出来,必须引入一个依赖模板参数的表达式。一般这样定义:
cpp复制template<typename> inline constexpr bool always_false = false;
然后写成:
cpp复制static_assert(always_false<T>, "unhandled shape type");
这样一旦 Shape 里加入了新的未知类型,编译期就会在这里停下来,明确提示还有未处理的分支。
3.2 这种写法最需要注意的几个坑
第一,不要用变量判断混进 if constexpr 条件。if constexpr 的条件必须能在编译期求值,它只和类型、常量表达式有关,和运行期变量无关。有人会写 if constexpr (shape.radius > 0),这是错误的表达,应该用普通 if。
第二,泛型 lambda 里的 auto 只是让 lambda 可以接受任何类型,真正的分支判断交给 if constexpr。如果你为了让代码看起来更“高级”而给每个分支写 requires,反而把问题搞复杂了。实际工程里 std::is_same_v 配合 if constexpr 已经足够直白。
第三,不要忽略 else 分支。如果没有 else,而且新增类型未被覆盖,函数可能因为没有返回值而编译失败,报错信息非常隐晦。加一个 static_assert 能帮你把错误定位清楚,这个习惯我强烈建议保留。
3.3 单 visitor 变体还能顺手解决空操作问题
事件系统里经常遇到这样的需求:Event 有几十种类型,某个监听者只关心两三种事件,其余全部忽略。
用 overloaded 时,可以加一个泛型 lambda 兜底:
cpp复制struct KeyDownEvent {};
struct MouseMoveEvent {};
struct WindowResized {};
using Event = std::variant<KeyDownEvent, MouseMoveEvent, WindowResized>;
void onEvent(const Event& e) {
std::visit(overloaded{
[](const KeyDownEvent&) {
// 只处理键盘按下
},
[](const auto&) {
// 其他事件忽略
}
}, e);
}
这就是“空访问者”的天然表达。经典 visitor 想实现同样效果,得写一个继承了所有 visit 的空实现基类,再继承它覆盖感兴趣的事件。而 overloaded + 泛型 lambda 让这件事变得几乎是白送的。
不过要提醒一下,当具体分支和泛型分支都能匹配时,编译器会优先选择更具体的重载。也就是说 KeyDownEvent 只匹配第一个 lambda,不会滑进 const auto& 里。
4. 多 variant 访问:一次 visit 拿到多个对象的分派
4.1 双对象甚至多对象分派,代码量反而更少
经典访问者模式只能对单个对象做 double dispatch。两个对象组合碰撞或者混合运算时,常规做法是写两个嵌套的 visitor,或者用 switch 和 RTTI 判断,非常难受。
std::visit 支持同时访问多个 variant。这个特性我越用越觉得值,简直是把访问者模式拓宽了一个维度。
举个碰撞检测的例子:
cpp复制struct Player {
int hp = 100;
};
struct Monster {
int attack = 10;
};
struct Bullet {
int damage = 30;
};
struct Obstacle {};
using ActorA = std::variant<Player, Monster, Bullet, Obstacle>;
using ActorB = std::variant<Player, Monster, Bullet, Obstacle>;
void collide(const ActorA& a, const ActorB& b) {
std::visit(overloaded{
[](const Player& p, const Monster& m) {
p.hp -= m.attack;
},
[](const Monster& m, const Bullet& b) {
// 怪物被子弹命中
},
[](const Player& p, const Bullet& b) {
// 玩家被子弹命中
},
[](const auto&, const auto&) {
// 默认无碰撞
}
}, a, b);
}
这个版本的可读性极高。分支条件写在参数列表中,哪个组合触发哪个行为一目了然,而且没有 RTTI 的 typeid 判断,没有任何虚函数。
4.2 编译期组合的代价与应对
std::visit 对多个 variant 的匹配,本质上是把它们的类型全部做笛卡尔积展开,然后在编译期排序、选择。类型数量为 N 和 M,组合数就是 N×M。
如果类型数量在个位数级别,这种展开完全无感。我在一个状态机里用过 44 种事件类型的 variant,双 variant 的 visit 也没有让编译时间明显恶化。但如果你的类型有一二十种,还要两个组合访问,就得留意编译时间和二进制体积了。
应对思路有两个:
- 用
const auto&泛型兜底把绝大多数组合折叠掉,只留下真正有业务逻辑的少量具体组合; - 把 variant 的类型列表拆小,比如把“角色类型”和“环境类型”拆成两个独立 variant,只在需要时组合访问。
实际业务里,碰撞检测、CPU 指令混合运算、矩阵与标量运算这类场景,组合数量其实远没有想象的那么爆炸,因为真正需要处理的有效组合通常是稀疏的。
4.3 多路访问的匹配顺序要注意
多参数 lambda 的重载决议遵循普通重载规则的“更特化优先”原则。
cpp复制std::visit(overloaded{
[](const auto& a, const auto& b) { /* 兜底 */ },
[](const Player& p, const Monster& m) { /* 特定组合 */ }
}, a, b);
当实际参数是 Player 和 Monster 时,第二个 lambda 因为参数类型更特化,会胜出。建议把兜底 lambda 放在最后,具体在前的顺序是我一直保持的习惯,这样代码阅读顺序也符合“先特例后一般”的自然思维。
5. 递归结构实战:表达式求值器里的访问者变体
5.1 用递归 variant 表达嵌套语法树
访问者模式最常见的应用场景之一就是处理 AST。用经典 visitor 实现 AST 求值,通常要定义一堆 Expr 派生类,每个类写 accept,visitor 里再逐个 visit。用 variant 写会简洁太多。
看一个带加、减、乘、除和数字字面量的表达式树:
cpp复制struct Expr;
struct Number {
double value = 0.0;
};
struct BinaryOp {
char op = '+';
std::shared_ptr<Expr> lhs;
std::shared_ptr<Expr> rhs;
};
struct Expr : std::variant<Number, BinaryOp> {
using variant::variant;
};
这里用 shared_ptr 指向子表达式,是因为 variant 本身要求内部类型可拷贝、可移动,而 std::shared_ptr 天然满足。如果你非要用 unique_ptr,就得自己给 Expr 写拷贝构造,麻烦。工程里绝大多数 AST 不会对性能极端敏感,shared_ptr 是性价比最高的选择。
有一个点容易踩坑:Expr 定义时自身还是不完整类型,但 BinaryOp 里的 shared_ptr
5.2 求值器代码:一个完整的 std::visit
求值就是对这个递归 variant 做一次深度优先访问:
cpp复制double eval(const Expr& e) {
return std::visit(overloaded{
[](const Number& n) -> double {
return n.value;
},
[](const BinaryOp& b) -> double {
double lhs = eval(*b.lhs);
double rhs = eval(*b.rhs);
switch (b.op) {
case '+': return lhs + rhs;
case '-': return lhs - rhs;
case '*': return lhs * rhs;
case '/':
if (rhs == 0.0) {
throw std::runtime_error("division by zero");
}
return lhs / rhs;
default:
throw std::runtime_error("unknown operator");
}
}
}, e);
}
明显看到,每个 lambda 的返回类型都是 double,因为一个表达式无论如何都求值成 double,这个例子恰好是“所有分支返回同类型”的典型场景。也正因如此,std::visit 很适合用来求值 AST。
调用端构造表达式就方便了:
cpp复制// 构造 1 + 2 * 3
Expr number1 = Number{1.0};
Expr number2 = Number{2.0};
Expr number3 = Number{3.0};
Expr mul = BinaryOp{'*', std::make_shared<Expr>(number2), std::make_shared<Expr>(number3)};
Expr add = BinaryOp{'+', std::make_shared<Expr>(number1), std::make_shared<Expr>(mul)};
double result = eval(add); // 7.0
当然,工程级 AST 通常还会包含变量、函数调用、条件分支等节点。每添加一种节点类型,你就往 Expr 的 variant 列表里加一个结构体,然后让 eval 的 visit 里新增一个 lambda。编译器会强制你把没处理的新类型找出来。
5.3 把经典 Visitor 里的“遍历”和“访问”拆到更细
表达式求值之外,我还经常用这套变体实现 AST 的“访问者组合操作”。比如同时遍历两个表达式做常数折叠,或者同时遍历类型系统做静态检查。这些在两棵 AST 之间做一个同构变换的需求,经典 visitor 写起来极其痛苦,而多路 visit 一句就带过去了。
cpp复制Expr foldBinary(const Expr& a, const Expr& b) {
return std::visit(overloaded{
[](const Number& x, const Number& y) -> Expr {
return Number{x.value + y.value};
},
[](const auto&, const auto&) -> Expr {
throw std::runtime_error("cannot fold");
}
}, a, b);
}
这个例子未必是最完美的常数折叠实现,但它展示了重点:同时传入两个 variant,visit 自然会匹配到两个 Number 的“同构组合”。
6. 性能、安全与选型建议
6.1 虚函数 vs std::visit:到底快在哪
很多人担心 std::visit 会慢。真实情况往往相反,关键在于虚函数无法内联,而 std::visit 在编译器看来只是一组普通函数重载。
虚函数分派依赖运行时的 vptr,CPU 需要跳到一个地址,而这个地址事先无法预测,分支预测失败代价比较大。访问者模式如果用了两层虚函数,等于连续两次间接跳转,函数体还大概率不会被内联。std::visit 现代编译器通常会生成一张跳转表或者比较链,而且每个 lambda 都是普通可内联函数,数据也是栈上连续内存,缓存命中率高很多。
环境不同结果会有差异,但我的项目里做过一次实际测量:一个每秒几百万次的事件分发器,从虚函数版切到 visit 版之后,吞吐量大约提升了 30%。这不是玄学,是虚函数间接跳转与内联之间的差距。
内存上受益更大。经典多态对象必须用指针管理,堆分配带来的开销在低延迟场景下很明显。variant 是值语义,直接嵌在容器或栈里,不需要分配堆内存。对高频小对象来说,这个差异往往比函数分派本身还重要。
6.2 需要警惕的 valueless_by_exception
std::variant 有一个相对隐蔽的状态叫 valueless_by_exception。当 variant 中当前类型对象的构造函数在赋值或 emplace 过程中抛异常时,variant 会放弃持有任何值,进入这个特殊状态。此时如果调用 std::visit,会直接抛出 std::bad_variant_access。
工程里我一般采取两个措施:
- 让存入 variant 的类型构造函数尽量 noexcept。比如这里面的类型基本就是几个 struct 加 shared_ptr,shared_ptr 的拷贝不会抛异常,天然安全;
- 在极端严谨的场景下,使用前检查
valueless_by_exception(),做好兜底。
variant 还支持 std::monostate,相当于显式的“空状态”。这比经典 visitor 里用一个空对象占位要规范得多,很多协议、状态机代码里我都用它表示无值。
6.3 什么时候我仍然写经典 Visitor,而不是 variant
variant 不是万能的,下面这些情况我会继续用经典 visitor:
- 类型列表需要在运行时通过动态库扩展。variant 的类型集合是编译期固定的,不可能在 so/dll 里注册一个新类型进去。插件体系必须靠虚函数。
- 需要稳定的 ABI 边界。虚函数表在这种场景下是事实标准,variant 的布局和实现细节跨编译器和版本并不能保证稳定。
- 老项目还在 C++14 及以下,又不想引入 boost.variant,那只能写经典 implement 或退而求其次手工写 switch。
- 类型数量非常多,比如上百种,且每个分支都很重。这时候编译期展开的代码量会明显上升,虚函数表的成本相对固定,反而更能控制全局代码体积。
选型时我习惯做个小对比:
| 维度 | 经典 Visitor | std::variant + std::visit |
|---|---|---|
| 类型扩展 | 需要改 Visitor 接口,所有访问者跟随修改 | 改 variant 列表,所有 visit 调用点被编译器提示 |
| 操作扩展 | 新增访问者类即可 | 新增一个 visit 调用即可 |
| 分派方式 | 运行时 vptr 双重间接跳转 | 编译期生成的跳转表/比较链,可内联 |
| 对象存储 | 通常是堆上的多态对象 | 值语义,栈上,无堆分配 |
| ABI / 动态库兼容 | 适合 | 不适合 |
| 类型集合运行时扩展 | 支持 | 不支持 |
| 编译期穷尽检查 | 无,靠人工补全 | 有,编译器强制 |
| 代码样板 | 每个类和访问者都有不少样板 | 几乎无样板 |
我的结论很明确:如果你的类型集合是稳定的、你知道完整列表,而且你主要在做业务逻辑,variant 这套变体几乎总是更好的选择。如果你在做框架层、插件层,或者需要跨 ABI 交付,经典 visitor 依然是更稳的底牌。
6.4 平滑迁移的一个思路
最后分享一个我实际用过的迁移路径。如果核心类型已经在项目里用经典继承体系跑了好几年,不要一次性推翻。我会在保留旧接口的同时,构造一个 variant 版本的新类型集合,写一层薄薄的转换函数:
- 旧对象转成 variant 对象,交给新逻辑处理;
- 新逻辑全部用 std::visit 实现;
- 等所有逻辑都迁移完后,再把旧接口删掉。
这层转换虽然有点土,但好处是可以小步迭代,每步都有测试兜底,不会让整个项目一夜之间陷入大改。访问者模式里很多问题,其实不是模式本身的错,而是 C++ 老写法给你塞了太多不必要的负担。换一套载体,体验完全不一样。
