访问者模式在C++圈子里是个挺有意思的存在。教科书把它列为经典设计模式之一,可实际项目里愿意用的团队没那么多,很多人一听到“双重分派”就头疼,更常见的做法是遇到类型判断就堆if-else,或者干脆推倒重来用std::variant。我自己最早接触访问者模式是做图像处理Demo的节点系统,后来在表达式引擎、文档导出这类项目里反复使用,踩过不少坑,才慢慢摸清楚它适合什么场景、不适合什么场景。这篇文章把实战心得完整整理一遍,从最经典的虚函数实现讲到现代C++的variant方案,再对比两者的取舍。文章面向有C++基础、知道多态但不太清楚设计模式怎么落地的读者,看完之后起码能在自己的项目里判断“该不该上访问者”,以及“上了之后怎么写才不翻车”。
1. 访问者模式到底解决什么问题
1.1 从一次接口变更说起
假设你在维护一个绘图程序,里面有Circle、Rectangle、Triangle三种形状,基类是Shape,每个派生类都实现了虚函数draw()。后来需求变了,要加面积计算,你在每个类里加一个area();再加周长,又得在每个类里加一个perimeter();再加序列化,再加坐标变换……需求每多一个“对所有类型的统一操作”,你就得改一遍所有派生类。
这种方式带来的问题很明显:类型数量是稳定的,操作却在不断膨胀。每加一个新操作,等于把全部类型打一遍补丁,改了A漏了B这种低级错误特别容易发生。访问者模式的核心思想就是把“操作”从类型体系里抽出来。类型保持“我只是一个数据容器,我接受外部的访问者”,而操作集中到独立的访问者类里。这样加新操作的时候,只需要新写一个访问者子类,原来的Shape体系一动不动。
项目里判断是否上访问者的第一个信号就是:你的类型集合是否基本稳定、操作集合是否经常扩展。反过来如果类型经常增加,那访问者模式就是给自己找麻烦,这一点后面专门讲。
1.2 双重分派:访问者模式的核心机制
C++的多态是“单分派”的:调用虚函数时,运行时行为由对象的实际类型决定,但函数本身的签名、重载选择在编译期就定死了。访问者模式要解决的场景是:行为既取决于元素的类型,又取决于访问者自己的类型。比如“圆形 + HTML导出”是一个行为,“表格 + Markdown导出”是另一个行为,这需要运行时同时知道两个对象的类型。
垫脚石是元素类里的accept()虚函数。调用链是这样:外部持有Shape基类指针,调用accept(visitor),此时Shape的实际类型分派到Circle::accept()或Rectangle::accept();然后accept内部执行visitor.visit(this),注意这里的this类型在编译期已经是Circle或Rectangle了,于是visit的哪个重载是编译期决定的,但对visitor的调用是动态的,visitor的实际类型又决定了最终走到AreaVisitor::visit(Circle)还是SerializeVisitor::visit(Circle)。两次多态叠加,就实现了双重分派。
理解这个机制的关键点在于:visit()重载不需要写成虚函数,真正的虚分派发生在accept()这一层,visit重载的选择靠编译期重载决议。把这个想明白了,后续写代码就不会被“到底谁调用谁”绕晕。
2. 经典实现:用虚函数手写访问者
2.1 完整骨架:一个Shape系统
直接上代码。这个例子我建议你照着完整敲一遍,骨架虽然简单,但细节里全是坑。
#include <memory> #include <vector> #include <iostream> class Circle; class Rectangle; class Triangle; // 访问者接口:每个具体类型对应一个visit重载 class ShapeVisitor { public: virtual ~ShapeVisitor() = default; virtual void visit(const Circle& shape) = 0; virtual void visit(const Rectangle& shape) = 0; virtual void visit(const Triangle& shape) = 0; }; // 元素基类:唯一要暴露的虚接口就是accept class Shape { public: virtual ~Shape() = default; virtual void accept(ShapeVisitor& visitor) const = 0; }; class Circle : public Shape { public: explicit Circle(double r) : radius_(r) {} double getRadius() const { return radius_; } void accept(ShapeVisitor& visitor) const override { visitor.visit(*this); } private: double radius_; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : width_(w), height_(h) {} double getWidth() const { return width_; } double getHeight() const { return height_; } void accept(ShapeVisitor& visitor) const override { visitor.visit(*this); } private: double width_; double height_; }; class Triangle : public Shape { public: Triangle(double b, double h) : base_(b), height_(h) {} double getBase() const { return base_; } double getHeight() const { return height_; } void accept(ShapeVisitor& visitor) const override { visitor.visit(*this); } private: double base_; double height_; };有了骨架,写一个面积计算访问者,你会发现新操作不需要改任何Shape类:
class AreaVisitor : public ShapeVisitor { public: void visit(const Circle& shape) override { result_ = 3.1415926535898 * shape.getRadius() * shape.getRadius(); } void visit(const Rectangle& shape) override { result_ = shape.getWidth() * shape.getHeight(); } void visit(const Triangle& shape) override { result_ = 0.5 * shape.getBase() * shape.getHeight(); } double getResult() const { return result_; } private: double result_ = 0.0; };这个AreaVisitor把“计算面积”这种横切所有类型的行为收敛到了一个类里。后续再加周长、序列化、绘制,就是再写一个Visitor子类的事,Shape体系完全不用动。调用端非常直观,把具体访问者实例传给每个Shape的accept即可:
void demo() { std::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(2.0)); shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0)); shapes.push_back(std::make_unique<Triangle>(5.0, 2.0)); AreaVisitor area; for (const auto& s : shapes) { s->accept(area); std::cout << "面积: " << area.getResult() << std::endl; } }很多教程到这里就结束了,但实际工程里还要注意一点:这里的visit参数都是const引用,也就是说访问者只能读取元素数据。如果某个操作需要修改元素内容,就得把接口改成非const版本,或者额外维护一套非const的visit重载。这个选择直接影响后面所有访问者子类的写法,建议在项目一开始就定好规范。
2.2 访问者的两种形态:有状态与无状态
上面AreaVisitor就是“有状态访问者”:每次visit把一个结果写进成员变量result_,外部通过getResult()取。这种写法好处是简单直观,坏处是访问者不能复用于并发场景——多个线程共享同一个AreaVisitor实例会数据竞争。如果只是为了求值,更推荐“无状态访问者”,让visit直接返回值。但纯虚版本的visit返回void,想返回值就得把访问者接口模板化:
template<typename T> class ShapeVisitorWithResult { public: virtual ~ShapeVisitorWithResult() = default; virtual T visit(const Circle& shape) = 0; virtual T visit(const Rectangle& shape) = 0; virtual T visit(const Triangle& shape) = 0; }; class AreaVisitor2 : public ShapeVisitorWithResult<double> { public: double visit(const Circle& shape) override { return 3.1415926535898 * shape.getRadius() * shape.getRadius(); } double visit(const Rectangle& shape) override { return shape.getWidth() * shape.getHeight(); } double visit(const Triangle& shape) override { return 0.5 * shape.getBase() * shape.getHeight(); } };模板化接口的代价是,所有visit返回类型必须一致,想从同一个元素上同时拿面积和周长就不方便了。实际工程项目里我见过两种风格混着用:纯计算类的访问者尽量无状态返回,涉及“一边遍历一边收集输出”的场景用有状态访问者,把输出流引用或者结果容器作为成员挂进去,效果很顺手。
3. 现代C++方案:std::variant与std::visit
3.1 用variant告别继承体系
C++17引入了std::variant后,很多原本用访问者模式解决的问题有了更轻量的表达。核心思路是把“类型集合”从继承层次改成variant类型,将“新增操作”从继承访问者改成重载operator(),再用std::visit触发分派。
#include <variant> #include <iostream> #include <vector> struct Circle { double radius; }; struct Rectangle { double width; double height; }; struct Triangle { double base; double height; }; using Shape = std::variant<Circle, Rectangle, Triangle>; struct AreaVisitor { double operator()(const Circle& c) const { return 3.1415926535898 * c.radius * c.radius; } double operator()(const Rectangle& r) const { return r.width * r.height; } double operator()(const Triangle& t) const { return 0.5 * t.base * t.height; } }; void demo() { std::vector<Shape> shapes{ Circle{2.0}, Rectangle{3.0, 4.0}, Triangle{5.0, 2.0} }; for (const auto& s : shapes) { std::cout << std::visit(AreaVisitor{}, s) << std::endl; } }对比经典继承方案,variant方案有几个肉眼可见的好处:元素是普通struct,不用写accept、不用管虚析构;容器直接存值,内存连续、缓存友好;std::visit可以采用表驱动或switch展开,现代编译器在优化良好的情况下能生成比虚函数调用更快的分派代码。最被低估的一点是,编译器强制你把所有类型处理完——漏掉一个类型的operator()直接编译报错,而经典访问者如果漏写visit重载,往往要等到运行时才知道。
3.2 Overload模式与递归变体
std::visit一个很爽的搭档是重载的泛型组合。C++17没有原生模式匹配,但可以用“使用所有基类operator()的聚合体”来模拟:
template<typename... Ts> struct Overload : Ts... { using Ts::operator()...; }; template<typename... Ts> Overload(Ts...) -> Overload<Ts...>; // 需要先定义 using Shape = std::variant<Circle, Rectangle, Triangle>; Shape shape = Rectangle{3.0, 4.0}; double area = std::visit(Overload{ [](const Circle& c) { return 3.1415926535898 * c.radius * c.radius; }, [](const Rectangle& r) { return r.width * r.height; }, [](const Triangle& t) { return 0.5 * t.base * t.height; } }, shape);用Overload可以在调用现场直接定义所有分支逻辑,不需要给每种操作单独定义一个类,代码看起来紧凑很多。要是想给一个分支统一的兜底行为,还可以混入泛型lambda——这一点在经典访问者里很难做到,因为visit重载是静态决议的,泛型兜底天然适配variant方案。
递归变体是variant方案的进阶用法,适用于表达式树这类自引用结构:
#include <memory> #include <variant> struct Number; struct AddExpr; struct MulExpr; using Expr = std::variant<Number, AddExpr, MulExpr>; struct Number { double value; }; struct AddExpr { std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; }; struct MulExpr { std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; };注意递归variant不能直接内含variant成员,因为会导致无限递归展开,必须用unique_ptr隔一层。求值器可以这样递归访问:
struct Evaluator { double operator()(const Number& n) const { return n.value; } double operator()(const AddExpr& e) const { return std::visit(*this, *e.left) + std::visit(*this, *e.right); } double operator()(const MulExpr& e) const { return std::visit(*this, *e.left) * std::visit(*this, *e.right); } };std::visit(*this, *e.left)这行里,*this作为一个可调用对象同时满足Number、AddExpr、MulExpr三种情况的处理,递归就能一层层走到叶子节点。第一次写这个的时候容易懵,明白了之后就再也回不去手写动态分派的表达式求值了。
4. 实战:文档导出系统怎么落地访问者
4.1 场景与架构决策
说个实战场景:一个内部文档工具的导出模块。文档由若干种节点组成——段落、表格、图片,后续还要加代码块、公式块。导出目标有多种格式:纯文本、Markdown、HTML,后续还可能加PDF。这里的特征很明显:类型集合基本稳定(扩张速度很慢),操作集合(导出格式)持续增加,而且每种导出对所有节点都要有一套处理逻辑。这正是访问者模式的典型适用面。
架构上采用继承式访问者,原因有三:文档节点类型虽然会扩张但频率低,加一个类型只需要改所有访问者,代价可控;导出逻辑本身比较复杂,每个导出器内部有大量的状态累积(比如Markdown导出器要维护列表缩进层级、表格分隔符状态),有状态访问者更自然;团队成员对虚函数版本更加熟悉,维护成本低。如果当时节点类型特别多且变更频繁,我会换用variant方案,这是后面要讲的核心取舍。
4.2 节点体系与导出器实现
节点侧代码保持经典三段式:访问者接口、元素基类、具体元素。区别是这里的节点数据直接作为公有成员暴露,因为导出器访问数据非常频繁,再包一层getter会让代码显得冗长。
#include <string> #include <vector> class ParagraphNode; class TableNode; class ImageNode; class DocNodeVisitor { public: virtual ~DocNodeVisitor() = default; virtual void visit(const ParagraphNode& node) = 0; virtual void visit(const TableNode& node) = 0; virtual void visit(const ImageNode& node) = 0; }; class DocNode { public: virtual ~DocNode() = default; virtual void accept(DocNodeVisitor& visitor) const = 0; }; class ParagraphNode : public DocNode { public: std::string text; std::string alignment; void accept(DocNodeVisitor& visitor) const override { visitor.visit(*this); } }; class TableNode : public DocNode { public: std::vector<std::vector<std::string>> cells; void accept(DocNodeVisitor& visitor) const override { visitor.visit(*this); } }; class ImageNode : public DocNode { public: std::string path; int width = 0; int height = 0; void accept(DocNodeVisitor& visitor) const override { visitor.visit(*this); } };Markdown导出器可以这样实现,注意表格分隔行的处理:
class MarkdownExporter : public DocNodeVisitor { public: void visit(const ParagraphNode& node) override { out_ += node.text + "\n\n"; } void visit(const TableNode& node) override { if (node.cells.empty()) return; appendRow(node.cells.front()); out_ += "|"; for (size_t i = 0; i < node.cells.front().size(); ++i) { out_ += "---|"; } out_ += "\n"; for (size_t i = 1; i < node.cells.size(); ++i) { appendRow(node.cells[i]); } out_ += "\n"; } void visit(const ImageNode& node) override { out_ += "\n\n"; } const std::string& result() const { return out_; } private: void appendRow(const std::vector<std::string>& row) { out_ += "|"; for (const auto& cell : row) { out_ += cell + "|"; } out_ += "\n"; } std::string out_; };HTML导出器只需要另写一个类,把每个节点翻译成对应标签即可。新增一种导出格式时,原节点体系统统不用动,这符合软件设计中“对扩展开放、对修改封闭”的追求。维护这段代码的时候有个心得:访问者的成员状态一定要严格初始化,比如out_在构造函数里置空,否则累积上一次导出的残留内容,排查起来相当痛苦。
4.3 遍历结构与策略调度
有一类常见需求是“一次遍历导出多种格式”,我们会把多个导出器传给一个统一调度器。这个调度器负责遍历节点列表并依次调用各节点的accept,把“遍历结构”与“节点处理”解耦。访问者模式中的accept调用链本身是深度遍历的骨架,但调度器可以改变遍历顺序。比如导出大表格前需要先统计图片数量,就可以用一个CountingVisitor先扫一遍再决定某些输出格式。这类“先摸底、再干活”的场景,用访问者模式实现起来非常自然。
实际项目里我还会给访问者加一个“是否可以处理未知节点”的兜底策略。C++纯虚接口会让漏实现的子类在编译期直接报错,这对导出器这种要求完整性极高的模块很友好;如果确实允许忽略某些节点,就可以给visit提供一个空实现的非纯虚版本。取舍点是团队风格和维护策略,不是技术能力问题。
5. 高频坑位与排查心得
5.1 循环依赖与头文件组织
用继承式访问者时最经典的坑是头文件循环包含。ShapeVisitor的visit重载要接收Circle、Rectangle的引用,必须看到完整定义;Circle::accept里调用visitor.visit(*this),只需要ShapeVisitor的完整定义。于是ShapeVisitor的头文件要包含所有元素类型,元素头文件又要包含访问者接口,肉眼可见的环。
解法是前向声明。访问者接口头文件里只写class Circle; class Rectangle;这些前向声明,visit的参数用引用,不需要完整定义。元素头文件里include访问者接口头文件。最后在.cpp里让二者都可见。我见过很多新人把class和struct混着写,导致class前向声明后接struct定义编译报错,这里要强调:前向声明必须和实际定义的关键字一致,用struct声明就用struct前向声明,不然后面继承或者实例化直接编译失败。
5.2 const语义与返回值处理
访问者接口的visit参数到底要不要写const引用?我见过不少团队在这上面反复改。经验是:如果你只读元素数据,参考上面的ShapeVisitor,全部用const Circle&这种写法,accept也写成const成员函数。这样visit内部改不了元素,代码意图清晰。但一旦某个导出器需要修改节点属性——比如给段落加一个“是否已导出”标记——就得引入非const重载版本,否则编译过不去。
此时可以考虑两套接口并存:const版本负责只读查询,非const版本负责修改。代价是每加一个元素类型就得多写两个visit声明,代码重复比较明显。另一种做法是给元素类模板化accept,写成模板成员函数转发调用,配合SFINAE约束,就能写一套代码同时支持const和非const。这个技巧适合模板基础好的团队,普通项目里两套接口已经够用。
还有一个被低估的细节:visit的返回值。经典访问者接口一般返回void,运算结果靠成员变量暂存。如果访问者被复用、而调用端忘记取结果,不会立刻报错,而是行为不符合预期——这个坑排查起来特别费时间。我现在的习惯是尽量使用无状态访问者,接口直接返回double或std::string,既能避免这类问题,也给以后并行化留了路。
5.3 继承树不扁平引发的“伪调度”
访问者模式对整个类型继承树有一个隐藏要求:具体元素类型应当“扁平”——不存在从某个具体元素继续派生的子类。一旦出现class SpecialCircle : public Circle,麻烦就来了。假设SpecialCircle没有重写accept,它继承自Circle::accept,那个accept里的*this虽然是SpecialCircle&,但参数绑定时会退化成Circle&(因为visit的重载列表里没有SpecialCircle版本),最终跑的是visit(const Circle&)分支。
这正是双重分派机制的精髓:分派到visit重载时依赖的是“承载accept的类”,而不是“对象动态类型”。所以要么约束好不许再派生具体元素,要么让每个最末级节点都重写accept,把自己精确传进去。我的经验是:无论哪种方案,都要在代码规范上写清楚——访问者模式要求元素类型的继承树深度只能是两层,基类加一级具体类型,禁止再往下派生。
5.4 std::visit的编译器和类型约束坑
换到variant方案也不是没坑。典型的是漏掉include。std::visit的编译错误往往异常晦涩:一个打印了三百行的模板错误堆栈里,编译器波浪线指到一个lambda表达式上,本质原因可能是某个类型不支持默认构造。定位这类错误的方法很老套但有效:把variant一个个类型拆开试,逐个排除。
还有一类坑是variant的“重复无效类型”导致的空间膨胀。std::variant保证至少能放下所有候选中最大的类型,但如果多个类型结构相似,比如Circle和Sphere都是double成员,空间占用看起来正常;一旦有padding问题,variant会比预期大不少。做紧凑型内存布局时,可以先用sizeof(std::variant<...>)实测一把,别依赖理论值。
6. 经典访问者 vs std::variant:怎么选
6.1 关键维度对比
经常有人问:“既然有std::visit,是不是访问者模式就该淘汰了?”我的回答是不该一刀切。两者解决的是同一类问题,但适用面不同。我按自己踩过的坑整理了一张对比表:
| 对比维度 | 经典访问者(继承+虚函数) | std::variant + std::visit |
|---|---|---|
| 类型集合扩展 | 需要修改所有访问者 | 需要修改variant定义和所有访问逻辑 |
| 操作集合扩展 | 新写一个访问者类即可 | 新写一个visit调用即可 |
| 元素存储方式 | 堆上指针,内存不连续 | 值语义,内存连续 |
| 分派性能 | 虚函数调用,通常两跳 | 可优化为switch/表驱动 |
| 编译期完整性 | 靠纯虚函数强制检查 | 编译器强制覆盖所有类型 |
| 类型层次支持 | 要求扁平,不允许继续派生 | 不涉及继承,天然扁平 |
| 有状态复杂算法 | 访问者类天然携带成员变量 | 需要额外结构持有状态 |
| 与外部库协作 | 需要元素提供accept接口 | 只要类型定义在,随时可visit |
6.2 我的选择经验与后期演进路径
如果类型集合非常稳定、操作需要持续增加,我倾向于经典访问者:语义直观,面向对象风格统一,团队成员容易接手。如果类型集合相对开放、但每种类型的数据结构差异很大,或者性能敏感的代码路径较多,我倾向variant方案。还有一种混合打法:对外保持继承体系,对内用variant做加速缓存。这个方案听起来高级,实际上条件复杂时维护成本极高,我不推荐刚开始用的人尝试。
另一个非常重要的经验是“以演进视角看待这个决定”。我在这上面吃过亏:给一个图形系统上了访问者模式,当时操作确实很多,后来业务扩展导致节点类型频繁增加,每次加类型都要改五六个访问者,开始痛苦。这时候评估后,把继承体系改成variant,虽然改了两天,换算下来反而是省时间的。反过来如果你先用variant写着写着发现类型的数据结构跨度增长,或者需要基于继承复用大量公共实现,那说明最初的设计选型就错了。
所以真正的答案是:在项目开始时花半小时把“类型扩张频率”和“操作扩张频率”两条线都画出来,优先选择与高频扩张方向匹配的方案。这是访问者模式所有实战经验里最核心的一条,比会写几个visit重载要重要得多。
最后分享一个小技巧:无论选哪种方案,把分派入口收敛到一处。经典访问者收敛为“所有accept调用都经过同一个公共基类指针”,variant收敛为“所有std::visit都通过同一个导出函数转发”。这样做的好处是出了问题只需要在几个地方打断点,排查成本低很多,也让后来接手的人一眼看到系统的分派中心在哪。我自己的习惯是,每个项目里访问者相关文件都放进单独目录,接口和实现分离,编译依赖会清晰很多——这个习惯救过我不少次。