C++访问者模式实战:双分派原理与std::variant选型指南
2026/9/24 21:48:47 网站建设 项目流程

如果你维护过那种实体类型不多、但操作一直在涨的C++项目,你多半会在某个版本迭代里遇到一个很头疼的问题:为了让日志系统支持一个新类型,得去改基类;为了让序列化模块兼容一个字段,又得去动所有派生类。我最早碰到这个场景是在一个轻量的渲染组件库里:图形对象就那么几种,但导出格式、碰撞检测、UI树生成这些功能却越加越多,每次加功能都要在类层次上开一道口子,很容易牵连到不相干的模块。后来我把这套组件改造成访问者模式,才真正体会到这个模式在C++里最迷人的地方——它把“操作”本身变成了一等公民。

这篇文章不打算从概念定义开始讲,而是直接带你看我在实际项目中怎么用访问者模式解决“类型稳定、需求善变”的问题,包括完整的代码实现、关键细节和踩坑记录。无论你是刚接触设计模式的新手,还是在老项目里被多态折腾过的C++程序员,这篇文章都能让你少走一段弯路。

1. 访问者模式到底解决了什么问题

1.1 当多态不再够用的时候

先说一个很常见的需求:你有一批形状对象,圆形、矩形、三角形,它们都继承自同一个抽象基类。你需要给它们分别计算面积、计算周长、导出JSON、绘制到屏幕。很多人第一反应是:在基类里加纯虚函数不就行了?面积一个虚函数,周长一个虚函数,导出再一个虚函数。确实,这种做法在小项目里没有问题,但一旦“操作”的数量开始膨胀,问题就来了。

每增加一个操作,都要在基类里加一个纯虚函数,然后所有派生类必须全部实现。更麻烦的是,如果某些操作只对部分类型有意义,比如只有圆形需要返回半径,只有多边形需要返回顶点数,基类接口会被一堆“特定类型”的方法污染,派生类里到处是“不支持此操作”的报错。我再补一刀:当操作由不同的人维护时,比如渲染主管加一个光栅化方法,碰撞工程师加一个射线求交方法,同一个类会被频繁修改,合并代码时的冲突概率直线上升。

访问者模式提供的思路是反过来的:既然类型集合相对稳定,那就把操作“抽出来”,让操作本身变成一个独立的类型。每个具体操作都是一个访问者类,它针对每个具体类型提供一个重载版本的访问逻辑。这样,新增操作时不需要动任何形状类,只需要新增一个访问者类,里面有对应类型的方法就够了。这就是所谓“对扩展开放,对修改封闭”的实践路径。

1.2 双分派:访问者模式的灵魂

C++里的普通虚函数调用是“单分派”的:调用哪个实现,只取决于对象的动态类型,而调用函数的参数重载在编译期就定死了。比如你写shape->draw(canvas),编译器虽然能通过基类指针调用派生类的draw,但如果你把Circle传给一个visit(Shape&)函数,函数内部调用的visit重载版本,只能按参数的静态类型来决定。

访问者模式之所以能绕过这个限制,靠的是“两次调用”的组合。第一次调用是动态分派:客户端调用element->accept(visitor),这个accept是虚函数,所以会调到Circle::acceptTriangle::accept;第二次调用是在具体类型的accept内部,它把自己this以具体类型传过去:visitor->visit(*this)。因为这里*this的静态类型已经确定是Circle,重载决议就能稳定地选到visit(Circle&)

所以访问者模式的本质是:用第一次虚函数调用拿到动态类型,把“类型信息”固化到具体类的成员函数里,再让第二次的重载决议拿到准确的静态类型。很多讲解把重点放在“访问者怎么实现”上,忽略了这“两次调用”的协作逻辑,导致读者只知道抄代码,不知道为什么要两步走。理解了这一点,你才会明白为什么访问者模式在C++里表现得像一个“手动扩展虚函数表”的机制。

2. 从零实现一个经典访问者模式

2.1 构建稳定的元素类层次

先设定一个最经典的场景:几何形状。我们有一个抽象基类Shape,派生类有CircleRectanglePoint。为了演示方便,我尽量把代码写完整,并配上注释,你直接抄到工程里也能跑。

首先是元素层的定义。每个具体元素都必须提供一个Accept方法,方法里只做一件事:调用访问者的Visit,把this以最具体的类型传出去。

#include <iostream> #include <memory> #include <vector> class Visitor; // 前置声明 class Shape { public: virtual ~Shape() = default; virtual void Accept(Visitor& v) = 0; }; class Circle : public Shape { public: double radius; explicit Circle(double r) : radius(r) {} void Accept(Visitor& v) override { v.Visit(*this); // 注意这里的 *this 是 Circle& } }; class Rectangle : public Shape { public: double width; double height; Rectangle(double w, double h) : width(w), height(h) {} void Accept(Visitor& v) override { v.Visit(*this); } }; class Point : public Shape { public: double x; double y; Point(double px, double py) : x(px), y(py) {} void Accept(Visitor& v) override { v.Visit(*this); } };

这里的Visitor只是一个前置声明,真正的定义在下面。Accept是一个虚函数,所以多态的魔法发生在第一次调用。每个具体类只需要写一行v.Visit(*this),看起来机械重复,但这是双分派机制不可省略的粘合层。

2.2 设计访问者接口

访问者接口里,需要为每一个具体元素类型声明一个Visit重载,参数是具体的引用类型。这一步是核心,也是访问者模式最机械的一环:元素列表定了之后,接口就不能轻易变。

class Visitor { public: virtual ~Visitor() = default; virtual void Visit(Circle& c) = 0; virtual void Visit(Rectangle& r) = 0; virtual void Visit(Point& p) = 0; };

注意三个重载参数的静态类型必须一一对应。如果在接口里写的是Visit(Shape& s),那你只是写了一个普通的重载函数,动态类型信息在第二次调用时就已经丢失了,访问者模式直接失效。所以这个接口的设计极其关键,参数必须是被访问的具体派生类引用。

从设计角度讲,访问者接口与元素接口是强耦合的:新增一个元素类型,所有访问者都要跟着改。因此在采用这个模式前,你要对“类型集合的稳定性”有足够信心。如果类型每天都在变,访问者模式会让你的维护成本成倍上涨,那种场景更适合用std::variant或者普通虚函数。

2.3 编写第一个具体访问者

现在我们可以写第一个具体的访问者:计算形状面积。它继承自Visitor,实现三个Visit重载。

class AreaVisitor : public Visitor { public: double total_area = 0.0; void Visit(Circle& c) override { total_area += 3.141592653589793 * c.radius * c.radius; } void Visit(Rectangle& r) override { total_area += r.width * r.height; } void Visit(Point& p) override { // 点没有面积,什么都不做 (void)p; } };

使用方式非常直观:创建一个访问者,循环遍历形状容器,逐个调用Accept,访问者内部就自动按真实类型收集了数据。

int main() { 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<Point>(1.0, 2.0)); AreaVisitor area_calc; for (auto& shape : shapes) { shape->Accept(area_calc); } std::cout << "Total area: " << area_calc.total_area << std::endl; return 0; }

这种方式的好处是:如果要加周长计算,不需要碰任何形状类,直接再写一个PerimeterVisitor就行。我在项目里做图形导出的思路也完全一样,写了一个ExportJsonVisitor,把每个形状的字段输出成JSON片段,类型越多,这种集中管理的优势越明显。

3. 在实操中不得不处理的关键细节

3.1 重载决议的陷阱:为什么不能只写一个Visit(Shape&)

我把这个放在第一个讲,因为几乎所有踩坑都源于这里。访问者模式能成立,依赖于C++的重载决议在编译期按静态类型匹配到正确的重载。在Circle::Accept内部,v.Visit(*this)里的*this,静态类型是Circle,所以会精确匹配Visit(Circle&)这个重载,而不是Visit(Shape&)

但如果你在实现类时写错了参数,比如在Circle::Accept里写了v.Visit(static_cast<Shape&>(*this)),或者访问者接口只有Visit(Shape&),那第二次调用就会丢失信息,静态类型退回成Shape。即便你自己写了一个带Shape&的重载,它也只能拿到基类引用,无法访问radiuswidth等派生类字段,整个访问就失去了意义。

有个很容易被忽略的细节是:如果访问者接口既有Visit(Shape&)又有Visit(Circle&),而在某个Accept里把*this转成了基类引用,程序仍然能编译运行,但走的是错误的分支。这种错误不会报编译器错误,只会表现为输出结果不对,排查起来比较恶心。我的建议是:接口里不要提供Visit(Shape&)这个兜底版本,除非你有意为之,否则它只会掩盖类型错误。

3.2 const 与返回值:两个绕不开的坎

默认的Visit方法接收的是非常量引用,这意味着如果我们的元素容器是const的,或者只想在只读场景里做统计,就得再派生一套 const 访问者,接口要变成Visit(const Circle&)。更麻烦的是,访问者需要返回值的时候,C++的重载设计会有取舍。

比如我想让CircleVisit返回面积,RectangleVisit返回面积,那么基类接口Visit(Circle&)Visit(Rectangle&)返回类型如果不同,纯虚函数定义就会产生麻烦,因为 C++ 允许协变返回类型,只对指针和引用类型有效,对doublestd::string这种值类型是不允许协变的。

我常用的方案有两种。第一种是方案是在访问者里设置成员变量,比如你刚才看到的total_area,访问后从访问者对象里取结果。第二种是用模板化辅助函数:

template<typename Result, typename Element, typename Visitor> Result ApplyVisitor(Element& e, Visitor v) { struct ResultCapture : Visitor { Result result; using Visitor::Visit; // 防止隐藏基类重载 }; // 具体实现略,思路是在内部捕获返回值 }

这里篇幅有限,我不把模板展开写。实际项目里,我更推荐用“访问者携带状态”的方式,因为代码最简单,而且便于累积多次访问的结果。如果你必须要返回值,用 C++17 的std::optional包一层也是一种不错的折中。

3.3 避免重复代码:用CRTP减少Accept的机械劳动

每个具体元素类都要写一个Accept方法,如果类型很多,这个重复劳动很烦。好在 C++ 里有 CRTP(奇异递归模板模式)可以把这个机械步骤收拢起来:

template<typename Derived> class ShapeAcceptHelper : public Shape { public: void Accept(Visitor& v) override { v.Visit(static_cast<Derived&>(*this)); } }; class Circle : public ShapeAcceptHelper<Circle> { public: double radius; explicit Circle(double r) : radius(r) {} }; class Rectangle : public ShapeAcceptHelper<Rectangle> { public: double width; double height; Rectangle(double w, double h) : width(w), height(h) {} };

这个技巧的核心是:模板基类知道具体派生类的类型,所以static_cast<Derived&>(*this)能把this以正确类型传给访问者。代码写起来清爽很多,也不容易发生“漏改某个Accept”的问题。不过要注意,CRTP 会破坏一部分人对继承关系的直觉,如果有人不小心把Derived写错,编译错报会相当难懂,所以放在团队协作的项目里需要斟酌。

3.4 访问者里的类型溢出与异常安全

用访问者模式做累加统计时,还要小心数值溢出。比如面积累加,如果图形数量很大,可以改用long double或分段累加。另一个容易被忽略的问题是异常:如果某个Visit里抛异常了,访问者的状态可能落在一个“积了一半”的尴尬节点上。我在做命令行工具的导出功能时,就在访问者内部维护了一个“事务性”标志,导出失败时把整个输出缓存清空,保证调用者拿到的是完整数据或明确错误,而不是半截内容。

4. 一个实际项目案例:绘图应用的导出与统计

4.1 需求背景与模块划分

为了让你看到访问者模式在真实项目里的完整形态,我拿一个简化过的绘图应用举例。背景是这样:应用里有图层、形状、文本三种页面元素,它们都继承自SceneNode。需求是支持两种导出格式:JSON 和 SVG,同时还要统计当前画布的面积、节点数量、某个点是否在元素内部。

如果不用访问者模式,我最开始的做法是给每个SceneNodeToJson()ToSvg()HitTest()三个虚函数。后来需求增加到五种格式,类里面全是序列化方法,代码已经没法看了。而且界面层的按钮还要根据“是否能导出”来控制状态,简直痛苦。

后来重构时,我把“场景节点”作为稳定的类型集合,把“导出格式”和“碰撞检测”作为访问者。整个模块划分变成:

  • 元素层:SceneNodeLayerNodeShapeNodeTextNode,只负责自身的几何数据和属性。
  • 访问者接口:SceneNodeVisitor,包含每个具体节点的Visit重载。
  • 具体访问者:JsonExporterSvgExporterAreaStatsHitTestVisitor

这样最直观的好处是:新增一种导出格式,只需要写一个新访问者,所有场景节点类一行都不用改。新增一种节点类型,才需要访问者接口上多一个纯虚函数,并让所有具体访问者补上实现,这两者的频率差异决定了我是否值得用访问者模式。

4.2 访问者与各种“杂务”的组合

在这个绘图应用里,JSON 导出访问者可以长这样:

class JsonExporter : public SceneNodeVisitor { public: std::string json; void Visit(LayerNode& node) override { json += "{ \"type\": \"layer\", \"name\": \"" + node.name + "\", \"children\": ["; // 遍历子节点,这里可能递归调用 Accept json += "] }"; } void Visit(ShapeNode& node) override { json += "{ \"type\": \"shape\", \"bbox\": [" + std::to_string(node.x) + ", " + std::to_string(node.y) + "] }"; } void Visit(TextNode& node) override { json += "{ \"type\": \"text\", \"content\": \"" + node.content + "\" }"; } };

SVG 导出访问者思路类似,只是输出标签。碰撞检测访问者则需要在内部维护一个“命中点”,然后根据具体类型决定是否返回 true。

从组织代码的角度看,访问者模式最大的贡献是让每个“业务动作”都变得内聚:一个访问者类只做一件事,它的成员变量就是这件事需要的临时状态。代码评审的时候,别人只需要看这个类,就能理解这个功能的全部逻辑,不用在十个类之间蹦来跳去。

4.3 场景里怎么遍历子节点

这里有个容易被忽略的问题:如果元素之间有树形结构,比如LayerNode里包含子节点,那么访问者模式怎么处理递归?我见过很多人在这里绕晕。其实很简单:LayerNode::Accept里,除了把自身传给访问者,还要负责遍历子节点,对每个子节点调用Accept。这个遍历逻辑放在LayerNode里,而不是放在访问者里,因为“子节点是LayerNode的内部结构”,按信息隐藏的原则,外部不应该知道如何遍历。

有些教程喜欢在访问者里遍历子节点,这会导致每次写访问者都重复一遍遍历代码,还不如放在元素类里。按我的经验,遍历可以固化成基类方法AcceptChildrenLayerNode::Accept先访问自己,再访问所有子节点,这样各组件的职责最清晰。

5. 常见问题与排查技巧实录

我在各种项目里折腾访问者模式,遇到过的典型问题基本都集中在下面这张表里。建议收藏,出问题的时候逐条对照。

现象根本原因排查与修复方案
访问者里的某个Visit分支永远不执行Accept*this被转换成基类引用,或传参写错了静态类型检查Accept里是不是v.Visit(*this),不要手动 cast 成Shape&
编译报错提示纯虚函数未实现访问者接口新增了某个Visit重载,但现有具体访问者没全部实现在接口中加新的纯虚函数时,立刻让所有访问者类实现一遍,否则会出现编译期“半成品”状态
想返回double类型,结果不得不改访问者接口值类型不支持协变返回类型改用访问者成员变量记录结果,或引入模板辅助捕获结果
派生类对象被访问时走到了Visit(Base&)访问者接口为了“方便”加了一个基类重载作为兜底去掉兜底重载;访问者接口应当只有具体类型的重载
访问者访问 const 元素时编译失败AcceptVisit的参数没有 const 版本const修饰Accept,并提供Visit(const XXX&)重载
某个新类型加入后,所有访问者都要改,改动量巨大类型集合本身不稳定重新评估:类型是否经常变化?如果是,优先考虑std::variant等其他方案
递归结构中访问者被重复调用元素中既有父节点访问又有子节点访问,叠加导致重复计算在访问者类里加visited_集合或设计上避免父节点代表整个子树被访问两次

5.1 重载决议陷阱的实战排查

我印象最深的一次是,项目里有一个ShapeGroup,它里面保存了一组子形状,Accept里既调用了visitor.Visit(*this),又遍历调用了子形状的Accept。然后我写了一个TotalAreaVisitor,一开始想当然地以为访问ShapeGroup时面积会自动累加子形状的面积,结果每次只统计了ShapeGroup自己的字段,子形状根本没被访问到。

排查半天,发现ShapeGroup::Accept里遍历子节点那行代码写在了“条件判断”下面,有一个分支直接return了,导致子节点访问被跳过了。这类问题不会报错,只表现为数值偏低,所以我后来给访问者加了日志输出,把每次访问的类型和参数打印出来,才定位到问题。这也说明一个经验:访问者模式调试起来,最好让Visit函数在入口处打印一条调试信息,否则“哪种类型的哪个分支被访问”很难直观感知。

5.2 当访问者模式撞上继承层次的扩展

假设你有一个SpecialCircle : Circle,而你只想让SpecialCircleCircle的访问逻辑,这没有问题,因为SpecialCircle::Accept如果没有 override,就会继承Circle::Accept,访问的时候Visit(Circle&)被调用。但如果你想让SpecialCircle有自己的访问逻辑,就需要在SpecialCircle里再次 overrideAccept,让它传*thisVisit(SpecialCircle&),同时最好在访问者接口里加一个Visit(SpecialCircle&)的新重载。

这里有一个隐藏的编译陷阱:如果SpecialCircle确实 override 了Accept,但访问者接口里没有Visit(SpecialCircle&),编译器就会尝试把SpecialCircle&绑定到Visit(Circle&),看起来也能编译,但如果你同时有更匹配的重载,行为就取决于重载排序。这种“意外拉起父类重载”的行为,容易让程序在类型扩展后保持旧的、多半是错误的逻辑。我的建议是:给访问者接口写一个静态断言或文档约定,每种具体元素类型必须有对应的Visit重载,尽量避免“隐式向上匹配”。

5.3 性能相关:访问者模式会慢到不可用吗

很多人担心访问者模式会引入虚函数调用,性能变差。实测下来,两次虚函数调用的开销在绝大多数业务场景里可以忽略不计。一次虚函数调用大概是几条指令和一次间接跳转,两个调用加在一起也只是微秒级以下的开销。如果你的访问逻辑本身涉及字符串拼接、文件写入、浮点计算,这点开销连零头都算不上。

但有几种场景确实需要小心。第一是超大集合的批量计算,比如几百万个节点的遍历,这时可以考虑用std::variantstd::visit,编译器可以用索引跳转,比虚函数少一层间接性。第二是访问者被频繁创建销毁,比如每帧几万次访问,这时最好复用访问者对象,把临时状态在访问前清空。第三是如果访问逻辑里还要访问容器元素,比如std::vector的动态扩容,那么瓶颈根本不在访问者模式本身,而在容器选择上。

6. 现代C++的替代方案:std::visit 和运行时快照

6.1 用 std::variant 替代继承体系

如果你使用的是 C++17 及以后的标准,那么有另一种实现“双分派”的现代做法:把类型集合定义成std::variant,然后用std::visit传入一个 lambda 或函数对象来分派。

#include <variant> class Circle { public: double radius; }; class Rectangle { public: double width; double height; }; using ShapeVariant = std::variant<Circle, Rectangle>; double GetArea(const ShapeVariant& shape) { return std::visit([](auto&& s) -> double { using T = std::decay_t<decltype(s)>; if constexpr (std::is_same_v<T, Circle>) { return 3.141592653589793 * s.radius * s.radius; } else if constexpr (std::is_same_v<T, Rectangle>) { return s.width * s.height; } return 0.0; }, shape); }

这种做法没有虚函数,没有继承,直接用variant存储具体对象,visit内部通过索引或指针跳转来分派。好处是代码更紧凑、性能更好、类型安全更强;坏处是类型集合写在variant的定义里,依然有“新增类型要改动大集合”的成本,而且如果底层代码需要面向“一组可以是任意类型的对象”时,variant不如继承树灵活。

6.2 该怎么选

如果你想从这篇文章得到最实用的决策建议,我会说:如果类型集合非常稳定,且操作频繁增加,优先选择访问者模式,尤其是你已经有了一个标准的继承体系时。如果类型集合本身也在快速变化,或者你只想为局部代码解决“分派”问题,std::variant比访问者模式更省事。

还有一种中间做法:用std::shared_ptr<void>std::any存对象,配合运行时类型信息做转换,但在现代C++里这种写法已经不值得推荐,因为你没有拿到任何编译期的类型保证,出错全靠运行时报异常。访问者模式虽然模板代码多,但至少每个类型的处理逻辑在编译期就定了,错不了。

从我个人经验来看,访问者模式在C++里最核心的价值恰恰在于它的“笨拙”:它在明确地提醒开发人员,你正在一个类型系统基本固定的世界里,为一系列操作提供稳定入口。这种缺陷与优点的平衡,让它在实际工程里几乎不会出现“滥用几万行实现不了”的场面。你只需要记住:每次往访问者接口里加一个新的Visit重载时,都在做一个未来向的承诺——这个类型会长期存在,并且值得为它写操作逻辑。

最后说一个小技巧:我在设计访问者接口时,习惯在纯虚函数声明旁边写注释,标注这个Visit对应的具体类型是从哪个场景里提取的。比如// Visit(Circle&) 用于几何计算和SVG导出。注释看起来有点啰嗦,但在几个月后重新维护时,能帮你快速建立“类型→场景”的映射,不会把一个只该用在导出的访问逻辑错误地复用到碰撞检测上。

访问者模式不是银弹,但当你手里握着一棵很少新添节点、功能却不断变化的类树时,它确实是那个能让你少改很多代码、少掉不少头发的方案。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询