访问者模式在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::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(2.0)); shapes.push_back(std::make_uniqueRectangle(3.0, 4.0)); shapes.push_back(std::make_uniqueTriangle(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想返回值就得把访问者接口模板化templatetypename 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 ShapeVisitorWithResultdouble { 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::visit3.1 用variant告别继承体系C17引入了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::variantCircle, 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::vectorShape 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一个很爽的搭档是重载的泛型组合。C17没有原生模式匹配但可以用“使用所有基类operator()的聚合体”来模拟templatetypename... Ts struct Overload : Ts... { using Ts::operator()...; }; templatetypename... Ts Overload(Ts...) - OverloadTs...; // 需要先定义 using Shape std::variantCircle, 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::variantNumber, AddExpr, MulExpr; struct Number { double value; }; struct AddExpr { std::unique_ptrExpr left; std::unique_ptrExpr right; }; struct MulExpr { std::unique_ptrExpr left; std::unique_ptrExpr 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::vectorstd::vectorstd::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::vectorstd::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接口只要类型定义在随时可visit6.2 我的选择经验与后期演进路径如果类型集合非常稳定、操作需要持续增加我倾向于经典访问者语义直观面向对象风格统一团队成员容易接手。如果类型集合相对开放、但每种类型的数据结构差异很大或者性能敏感的代码路径较多我倾向variant方案。还有一种混合打法对外保持继承体系对内用variant做加速缓存。这个方案听起来高级实际上条件复杂时维护成本极高我不推荐刚开始用的人尝试。另一个非常重要的经验是“以演进视角看待这个决定”。我在这上面吃过亏给一个图形系统上了访问者模式当时操作确实很多后来业务扩展导致节点类型频繁增加每次加类型都要改五六个访问者开始痛苦。这时候评估后把继承体系改成variant虽然改了两天换算下来反而是省时间的。反过来如果你先用variant写着写着发现类型的数据结构跨度增长或者需要基于继承复用大量公共实现那说明最初的设计选型就错了。所以真正的答案是在项目开始时花半小时把“类型扩张频率”和“操作扩张频率”两条线都画出来优先选择与高频扩张方向匹配的方案。这是访问者模式所有实战经验里最核心的一条比会写几个visit重载要重要得多。最后分享一个小技巧无论选哪种方案把分派入口收敛到一处。经典访问者收敛为“所有accept调用都经过同一个公共基类指针”variant收敛为“所有std::visit都通过同一个导出函数转发”。这样做的好处是出了问题只需要在几个地方打断点排查成本低很多也让后来接手的人一眼看到系统的分派中心在哪。我自己的习惯是每个项目里访问者相关文件都放进单独目录接口和实现分离编译依赖会清晰很多——这个习惯救过我不少次。