有个场景我印象特别深。项目里从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 {
