ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C++访问者模式实战:从手写实现到std::variant+std::visit

C++访问者模式实战:从手写实现到std::variant+std::visit 最近接了个活儿要在一套已经跑了好几年的C图形库里加“导出JSON”的功能。图形库里各种形状类Circle、Rectangle、Polygon继承关系错综复杂还带OpenGL渲染的耦合。我第一反应是给每个类都加一个toJson()方法结果改到第三个子类就改不下去了——每个类里都塞进去一段和图形计算毫无关系的序列化逻辑而且下一个迭代据说还要加XML导出、加统计信息、加一份给前端用的轻量版本。我心里很清楚这条路走下去这个库会变成什么鬼样子。后来我把这套东西重构成了访问者模式Visitor Pattern一周之内搞定了所有导出操作后续加新操作再也没碰过那些图形类本身。这篇文章就从这次实战经历出发把访问者模式从“概念图”到“能落地的工程手段”完整拆一遍它到底解决了什么问题手写版怎么做什么样子的项目真正需要它以及现代C里std::variant std::visit这种更简洁的替代写法。适合刚学完设计模式但不知道怎么用到项目里的读者也适合正在纠结“要不要重构”的老手。1. 访问者模式要解决的核心矛盾类型稳定、操作多变很多讲设计模式的文章一上来就甩类图但你不理解它要干什么类图只能记住三分钟。我习惯从“加需求”这个最真实的角度来说。1.1 从“给每个形状加面积”说起假设你有一个对象结构struct Shape { virtual ~Shape() default; }; struct Circle : Shape { double radius 1.0; }; struct Rectangle : Shape { double width 2.0; double height 3.0; };现在产品提了个需求统计所有形状的面积。最直接的做法是给每个子类加一个area()虚函数。子类只有两个这没问题。但试想这个对象结构一旦固定下来后续需求是一波接一波地来面积、周长、包围盒、序列化成JSON、转成SVG、写进渲染队列、计算鼠标是否命中……如果每个需求都去给各个类“塞方法”会发生什么你其实是在反复修改同一个类族而每次修改都可能引入bug。更麻烦的是如果你拿到的是一套第三方库或者这套类被几个人同时维护你要加一个操作就得改所有子类这在工程协同上是个大坑。1.2 反过来看开闭原则类型数量与操作数量的矛盾大家都知道开闭原则——对扩展开放对修改关闭。但“扩展”这个词要分方向看一个是往类型里加新的子类另一个是往现有类型上叠新的操作。绝大多数情况下你只能稳定住其中一侧然后让另一侧自由增长。如果对象类型经常增加未来一个月能冒出十几个新Shape子类那各种操作用虚函数放在基类里反而好加一个子类你只需要实现这个子类自己的虚函数别的不用动。如果说类型基本固定Circle、Rectangle、Polygon都一年没变过但是操作需求一直在变序列化、统计、渲染、导出这就要考虑访问者模式了。访问者模式本质上是把“操作”从对象结构里剥出来让它成为独立的一组类。用一句人话概括对象结构负责稳定下来访问者负责无限扩展。这是它的核心价值。1.3 双分派这个模式为什么叫“访问者”虚函数在C里只做单分派——也就是根据接收者receiver的实际类型来决定调用哪个函数。你调用shape-area()编译器在运行时会根据shape指向的实际类型跳转到对应的Circle::area()还是Rectangle::area()这叫单分派。但访问者要解决的是“根据两个对象的实际类型来决定调用哪段逻辑”的问题。比如一个Visitor要同时知道我这个访问者要做的是什么操作它访问的具体是哪个对象这需要第二次动态分发。经典访问者的解法很巧妙先调用对象的虚函数accept()这一次分发来到了具体对象比如Circle的accept函数然后在这个函数里调用visitor.visit(*this)由于此时的*this已经被编译器确定了类型是Circle第二次分派就被固定成了对visitor.visit(Circle)的调用而这个调用本身也是虚函数——如果visitor是接口指针运行时还会再跳到具体visitor的visit(Circle)实现里。整个过程是外部分发到对象对象内部分发到visitor的重载函数。这是理解访问者模式的关键比背一千遍类图都管用。1.4 为什么不直接用 typeid if-else有人会问那我用typeid拿到类型名或者用dynamic_cast逐个试一遍然后再写对应的分支逻辑不也能实现同样的效果吗能但代码会长这样if (auto* c dynamic_castCircle*(shape)) { json[type] circle; json[radius] c-radius; } else if (auto* r dynamic_castRectangle*(shape)) { json[type] rectangle; json[width] r-width; json[height] r-height; }这样的if-else链散落在每一个需要做类型分支的地方。一旦操作多了每个操作里都有这么一长串而且没有编译期保证——如果哪天新增一个Triangle编译器不会主动提醒你这里漏了分支它是等到运行时才发现类型匹配不上静默出错。访问者模式把类型分支收拢进对象结构内部新增类型会导致接口变化编译器会直接列出所有需要改的地方。这一点在大型项目里的价值怎么强调都不过分。2. 手写经典访问者从接口定义到可运行代码概念说清楚了直接上代码。这里我把前面那个图形库例子落地成一套完整的访问者实现。2.1 先定义访问者接口访问者接口的核心是为每一个具体对象类型准备一个visit重载函数。#include memory #include vector class Circle; class Rectangle; class ShapeVisitor { public: virtual ~ShapeVisitor() default; virtual void visit(Circle c) 0; virtual void visit(Rectangle r) 0; };结构体类的定义需要在visitor接口之前出现所以要先用前置声明占位避免循环引用的编译问题。每个具体类型在visitor接口里都有一个对应的纯虚函数这是后面“新增类型会导致编译错误提醒”的保证。2.2 对象结构侧实现 accept现在给每个图形类加一个accept虚函数。注意这是对已有类族唯一的一次修改而且这个修改很小——只是加一个函数转发。class Shape { public: virtual ~Shape() default; virtual void accept(ShapeVisitor v) 0; }; class Circle : public Shape { public: double radius 1.0; void accept(ShapeVisitor v) override { v.visit(*this); // 这里 *this 已经被精确定为 Circle } }; class Rectangle : public Shape { public: double width 2.0; double height 3.0; void accept(ShapeVisitor v) override { v.visit(*this); // 这里 *this 已经被精确定为 Rectangle } };有的初学者会问“v.visit(*this) 为什么不走虚函数分派非要写visit(Circle)和visit(Rectangle)两个不同的函数”因为C的重载决议是静态的编译期间编译器看到*this的具体类型是Circle就会直接把调用解析到ShapeVisitor::visit(Circle)你不可能靠一个visit(Shape)来实现“自动识别”。重载决议与动态绑定不是一回事这正好是“第二次分派”的落地方式。2.3 完整示例两个访问者跑起来有了接口和accept写一个面积访问者#include iostream class AreaVisitor : public ShapeVisitor { public: double total 0.0; void visit(Circle c) override { total 3.141592653589793 * c.radius * c.radius; } void visit(Rectangle r) override { total r.width * r.height; } }; int main() { std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle(Circle{2.0})); shapes.push_back(std::make_uniqueRectangle(Rectangle{3.0, 4.0})); AreaVisitor av; for (auto s : shapes) { s-accept(av); } std::cout total area av.total std::endl; return 0; }再写一个JSON导出访问者class JsonVisitor : public ShapeVisitor { public: std::string result; void visit(Circle c) override { result {\type\:\circle\,\radius\: std::to_string(c.radius) }; } void visit(Rectangle r) override { result {\type\:\rectangle\,\width\: std::to_string(r.width) ,\height\: std::to_string(r.height) }; } };新增一个操作时只需要新写一个继承ShapeVisitor的类图形类一个都不用动。这才是访问者模式真正的使用体验——加操作改新文件加类型才动旧文件。2.4 几个实战中容易踩的细节代码看着简单但在真实工程里有几个点容易被忽略第一接口析构函数必须是virtual。访问者经常会持有std::shared_ptrShapeVisitor或通过基类指针释放析构函数不声明为虚函数删除自然是未定义行为。这一点和所有抽象基类一样。第二头文件前向声明要仔细。如果一个visitor的visit函数需要访问Circle的成员那visitor的实现文件必须包含Circle的完整定义接口头文件可以只前向声明。但accept的实现放在类内所以ShapeVisitor必须在Shape类文件之前被包含或者前置声明否则编译器无法识别类型。混乱的头文件依赖是C项目里最常见的问题访问者模式因为是双向依赖尤其容易踩。第三默认参数和重载决议的坑。如果interface里同时有visit(Circle)和visit(const Circle)两个重载那么Circle::accept里调用v.visit(*this)时最后会匹配到visit(Circle)。但如果你希望接受const对象则要小心传递的是“常量性”否则编译器会因为无法绑定出现错误。其实更干净的做法是在visitor接口中同时提供const版本或者专门做const访问者后面第6节会展开。3. 真实工程项目里访问者模式的用武之地很多人看完上面的例子觉得“这不就是把if-else换了个写法嘛”然后就没有然后了。确实等到真正需要它的时候往往项目已经糟到让人绝望。我根据自己用过的几个场景总结一下什么样的项目布局下访问者模式属于刚需。3.1 表达式树、AST、语法树编译器系项目的标配代码编译器的前端会把源代码解析成一棵抽象语法树AST节点类型有几十种但节点类别很难变。同一棵AST后面往往要跑很多遍操作语义分析、类型检查、生成中间代码、导出符号表、格式化源码。这些操作如果都写成虚函数放进AST节点里AST节点会膨胀到几千行哪怕只加一个代码优化的pass你都得去改所有节点类这叫“操作侵染了类型”。我自己给一个脚本语言做过一个表达式求值器表达式类型就是数字、变量、二元运算、函数调用一年到头也不会加新的表达式节点但需要不断加新功能——解释执行、编译成字节码、代码覆盖率统计、IDE悬停提示。用访问者模式把每个功能独立成一个visitor写起来反而是最顺手、最容易并行开发的。团队里三个人可以同时写不同的visitor代码目录下看得很清爽。3.2 事件分发 / 命令处理一个基类、无数个子系统处理游戏或GUI框架里常见这种场景有一个Event基类子类有一大堆——鼠标按下、鼠标移动、键盘按键、窗口尺寸变化、触摸事件、手柄连接。每种事件的结构都不一样但事件类型的增加远远慢于“上层业务里需要响应不同事件的处理模块”的增加。用访问者模式来做事件分发每个业务模块就是一个visitor里面只写自己关心的事件别的事件留空即可。这种场景如果用传统的“事件类型枚举 switch”每加一个业务模块都要把分发逻辑里的switch复制一遍而且每个模块都要case所有事件。访问者模式的价值和提纯逻辑是一样的把每个模块关心的代码从“散落各处”收拢进一个类里。3.3 游戏引擎 / 序列化的分层操作游戏中物理引擎、渲染引擎、粒子系统处理同一批“Actor”对象但每种系统对Actor的操作完全不同而且操作维度每个版本都在变。序列化也是访问者模式很典型的现实场景不同版本的存档格式需要不同的序列化visitor老版本读档一个visitor新版本读档一个visitor很自然地就能做兼容。3.4 怎么判断你的项目是否值得用我总结了几条非常“直觉化”的识别信号你可以拿自己的项目对照对象结构的类型数量是稳定的你很难想起上一次新增子类是几月前。反例如果你正在做一个插件系统插件里可以注册任意新的元素类型访问者就不适合。对同一组对象需要执行的操作还在不断增补。注意“操作”不只是计算也包含IO、渲染、事件处理、状态迁移等。每次要新增一个操作时你不想去改已有的对象类。如果在你的代码里这三点成立那就不是“要不要学访问者”的问题而是“再不用就要被if-else拖死了”。4. 访问者模式翻车现场重载爆炸和扩展地狱访问者模式不是银弹。用好了是手术刀用不好就是自找麻烦。这一节把我踩过的坑以及我在真实项目里见过的最典型的翻车方式都摆出来。4.1 痛点之一新增一个类型所有visitor都要跟着改如果你有一个比较复杂的类型层级有15个具体子类同时有8个visitor在代码库各处那么当你往层级里加第16个子类时编译错误会一下喷出8个文件——每个visitor都差一个纯虚函数的实现。我见过一个开源项目就是这么崩掉的团队成员加了个新节点类型漏改了两个visitor编译全过了因为那两个visitor不是从纯虚接口派生而是给每个visit函数都加了默认空实现。结果新类型的逻辑就静默丢失运行了一个月才被人发现可视化编辑器里少了这类节点。访问者模式的“编译期强制提示”全靠纯虚函数提供一旦为了偷懒加了默认实现安全网就撕开了口子。应对方案也很成熟如果你判断未来类型扩展的频率不低可以给visitor接口里的函数一个默认空实现但同时在accept里加运行时断言或者日志提示“此类型未被任何visitor处理”把静默失败变成显式警告。我的习惯是内部项目默认不做默认实现把所有不匹配暴露成编译错误跨团队协作时再考虑放宽因为队友被编译错误轰炸太多会产生负面情绪。4.2 痛点之二const重载、右值重载、智能指针组合的接口膨胀经典的visitor接口定义成visit(Circle)、visit(Rectangle)。但你的对象结构如果既被const容器持有也被非const容器持有很快你就需要const版本的visitor接口visit(const Circle)、visit(const Rectangle)。如果代码里偶尔有临时对象你还得考虑右值版本的visit(Circle)。三个维度叠起来接口数量直接翻倍维护成本直线上升。这个问题的根源在于访问者模式把“类型的多态”翻译为“函数重载的多态”而重载决议在编译期发生const、引用限定符都会影响决议结果这和虚函数的运行时多态在语义上有微妙区别——虚函数用基类指针就能自由处理访问者则必须精确匹配引用类型。工程上的妥协做法是要么统一只传非const引用需要读数据时用const_cast这有风险但很多代码库确实这么干要么把visitor拆成ConstShapeVisitor和ShapeVisitor两套各管各的。另外如果你的对象是用shared_ptr或者unique_ptr存取的还要考虑visit(std::shared_ptrCircle)这种变体接口会进一步膨胀这时候常用的替代方案是直接把裸引用传进visit在外部把智能指针解引用。4.3 痛点之三和“访问者需要返回值”纠缠不清名场面你写了个visitor但你的操作需要返回不只是double而是整个bool、std::optionalint、或者一个std::string加一个错误码。经典visitor的visit函数是void的这会逼着你在visitor里塞一个result_成员或者用一段内部状态获取函数。我在实际项目里的处理方式有三种最传统visitor内部持有一个结果变量通过getResult()暴露。简单但你要小心多次调用同一visitor时结果变量的重置。模板化让visit函数返回T用模板参数去实例化visitor。但模板和虚函数的组合在C里不能直接做——虚函数不能是模板函数成员所以这类方法往往需要额外的std::any或者type erasure支持。参数化context在visitor的构造函数里传入一个context对象所有visit函数把结果写进context。这个最灵活尤其适合需要积累多个输出结果的场景比如在面积计算时还要记录最大形状。我个人更倾向第一种和第三种因为读起来简单、调试方便。但无论哪种都会让代码比“虚函数的area()返回一个double”要绕。如果只是偶尔一两个操作其实没必要用访问者。4.4 我给一个项目做“访问者切除手术”的经历说个更老实的经验我在一个编辑器项目里最初三个元素类型用了访问者模式后来元素类型逐渐膨胀到15个而操作基本稳定在两三个。彼时每次加新类型都要去7个visitor文件里补visit重载交叉编译一次要五分钟团队内部怨声载道。我后来做了一次反向重构把visitor全部撤掉改成“基类定义默认实现 子类override关键操作”的普通多态方式。原因是这个代码库里操作的增长远远低于类型增长访问者模式的方向就选反了。那次重构教会我——模式本身没有好坏但方向选错就是灾难。初学访问者模式的人最容易犯的毛病是“手里拿着锤子看什么都像钉子”明明类型还在无限膨胀却固执地守着visitor不放。5. 现代C的正确打开方式std::variant std::visit如果你用的是C17及以后的标准上面那一堆接口定义、accept函数、visit重载有更清爽的替代方案用std::variant存储对象类型用std::visit做分派。很多人不知道std::visit本质上就是一个天然的访问者分发器你完全不用手写双分派的核心机制。5.1 std::variant 作为“内联对象结构”假设我们不建虚函数继承体系而是用variant直接承载所有可能类型#include variant #include string struct Circle { double radius 1.0; }; struct Rectangle { double width 2.0; double height 3.0; }; using Shape std::variantCircle, Rectangle;看起来就是把多态“拍平”了。variant要求类型表是封闭的——编译期就必须列出来所有可能类型。这正好符合访问者模式“类型稳定”的假设而且你省掉了继承、虚函数、vtable这些机制。5.2 std::visit 是怎么做 double dispatch 的std::visit(visitor, variant)会在运行时根据variant实际存储的索引调用visitor中对应的operator()重载。你传入的visitor可以是一组lambda的集合体这其实就是现代C里“访问者”的最终形态。最经典的做法是用一个叫overload的小工具把多个lambda重载叠加到一起templateclass... Ts struct overload : Ts... { using Ts::operator()...; }; templateclass... Ts overload(Ts...) - overloadTs...;有了它写一个“面积JSON导出”访问者简直不要太轻松#include iostream int main() { std::vectorShape shapes; shapes.push_back(Circle{2.0}); shapes.push_back(Rectangle{3.0, 4.0}); for (auto s : shapes) { std::visit(overload{ [](Circle c) { std::cout circle area 3.141592653589793 * c.radius * c.radius \n; }, [](Rectangle r) { std::cout rect area r.width * r.height \n; }, }, s); } return 0; }你不再需要单独定义一个visitor类也不需要给每个类型写accept了。想要再增加一种操作写一个新的std::visit调用传入另一组lambda即可。类型表新增一个变体所有std::visit调用都会在编译期检查是否处理了全部类型这一点和手写访问者的纯虚接口有着同样的安全保障。5.3 什么时候用std::variant什么时候用手写访问者两种方案都有自己适合的地盘我做了个对比表帮你快速决策对比维度经典访问者继承虚函数std::variant std::visit类型是否必须封闭不要求但扩展类型成本高必须封闭在variant的模板参数里对象存储方式需要指针/引用打包支持动态集合值语义vector 直接存缓存友好是否允许增量增加类型可以但会破坏所有visitor允许但会破坏所有std::visit调用与第三方类集成困难需要在类内加accept除非用非侵入变体容易lambda可以直接写运行时性能2次虚函数调用不大通常用整数索引分派比虚调用略快代码量模板代码多结构性重非常轻符合现代C风格依赖标准C98以上就能跑需要C17说句实话现在我自己新写的代码除非明确要用到继承体系提供二进制兼容或运行时多态否则我都优先考虑std::variant方案。但如果你手里已经是一套基于继承的旧代码里面到处都是std::unique_ptrShape、工厂函数那转成variant不现实手写访问者仍然是合适的路。5.4 注意点递归variant、monostate与错误处理使用variant时有几个坑我提前给你排掉。递归结构比如表达式树variant不能直接包含自身必须包一层std::unique_ptr或者用std::vector间接递归。struct Node; using NodePtr std::unique_ptrNode; struct Node { std::variantdouble, std::vectorNodePtr data; };std::visit没有“默认分支”lambda集合里必须为variant所有可能类型提供重载否则编译失败。如果你急需要一个兜底可以用一个模板lambda作为最后一个重载std::visit(overload{ [](const Circle c) { ... }, [](const auto other) { ... }, // 兜底分支 }, shape);不过这种写法的缺点是自动兜底后你不再有“新增类型未处理”的编译保护别随便用。variant的索引可以用shape.valueless_by_exception()判断是否处于无效状态一旦variant里存储的类型抛异常导致赋值失败会进入valueless状态。实际工程里很少遇到但处理外部输入时建议加个防御性检查。6. 进一步优化CRTP去掉样板代码、有返回值的访问者设计如果你最终还是要用手写访问者下面几个进阶技巧能让你的代码更优雅。这些是我在实际项目里反复试过之后沉淀下来的经验。6.1 用CRTP把accept样板代码收走手写访问者最烦的一点是每个子类都要写void accept(ShapeVisitor v) override { v.visit(*this); }类型少还好类型一多就是纯粹的体力活。用CRTPCuriously Recurring Template Pattern可以把这段样板代码提出来template typename Derived class VisitableShape : public Shape { public: void accept(ShapeVisitor v) override { v.visit(static_castDerived(*this)); } }; class Circle : public VisitableShapeCircle { public: double radius 1.0; }; class Rectangle : public VisitableShapeRectangle { public: double width 2.0; double height 3.0; };这样每个子类就完全没有accept的重复代码了。static_castDerived在编译期就能验证类型转换合法不会有运行时开销。这是一个很典型的CRTP用例。不过要注意CRTP要求Derived在定义VisitableShapeDerived时必须已经完整否则编译器不知道该怎么转型所以子类定义处要格外小心顺序。另外如果你后面还想再派生子类这个模式就不太灵了因为它是一层一层的静态绑定。6.2 有返回值访问者的三种设计再来解决“返回值难搞”的老问题。内部状态型最直接访问者内部持有累计值对外暴露getResult()。适合面积统计这类累加型操作。class AreaVisitor : public ShapeVisitor { double total_ 0.0; public: void visit(Circle c) override { total_ 3.14159265 * c.radius * c.radius; } void visit(Rectangle r) override { total_ r.width * r.height; } double getResult() const { return total_; } };上下文传递型访问者接收一个context引用把结果写到context里。适合需要报告多个输出值或者部分失败的场景。struct AnalysisContext { double totalArea 0.0; double totalPerimeter 0.0; }; class AnalyzeVisitor : public ShapeVisitor { AnalysisContext ctx_; public: explicit AnalyzeVisitor(AnalysisContext ctx) : ctx_(ctx) {} void visit(Circle c) override { ctx_.totalArea 3.14159265 * c.radius * c.radius; ctx_.totalPerimeter 2 * 3.14159265 * c.radius; } void visit(Rectangle r) override { ctx_.totalArea r.width * r.height; ctx_.totalPerimeter 2 * (r.width r.height); } };模板std::any型这里我提过一嘴用起来最贵也最灵活不建议优先选让visit函数返回T但std::any在运行时做类型抹除性能差只在边界偶尔用。我个人几乎只推荐前两种。访问者模式本身就已经比虚函数多绕一层了再把返回值搞成魔法代码的可读性会成倍下降队友会骂人。6.3 const正确性const对象访问者怎么设计前面提过const重载膨胀的问题这里给一个我常用的折中方案。如果你的对象集合是const的且你只读数据可以定义一整套const的visitor接口同时在非const接口里用const_cast做适配在确认没有任何代码会修改对象的前提下。但这种做法我建议收敛在很小范围内千万别到处用。更干净的做法是把const正确性做进模板里template typename Visitor void visitAll(const std::vectorstd::unique_ptrShape shapes, Visitor v) { for (auto s : shapes) { s-accept(v); } }如果你的accept重载里能同时处理const和非const的引用模板可以帮你统一。但坦白讲一个完整的const访问者实现链要在接口和accept间来回设计复杂度已经不低了。看到这里如果觉得头大就回到第5节想想要不要直接用std::variant。const变体在std::visit里只是lambda参数加const的问题零额外成本。6.4 综合示例一个更接近真实的访问者代码骨架最后给个相对完整的、综合了上面所有建议的代码骨架你可以直接拿去做模板#include iostream #include memory #include vector class Circle; class Rectangle; class ShapeVisitor { public: virtual ~ShapeVisitor() default; virtual void visit(Circle c) 0; virtual void visit(Rectangle r) 0; }; class Shape { public: virtual ~Shape() default; virtual void accept(ShapeVisitor v) 0; }; template typename Derived class VisitableShape : public Shape { public: void accept(ShapeVisitor v) override { v.visit(static_castDerived(*this)); } }; class Circle : public VisitableShapeCircle { public: double radius 1.0; }; class Rectangle : public VisitableShapeRectangle { public: double width 2.0; double height 3.0; }; class InfoVisitor : public ShapeVisitor { public: void visit(Circle c) override { std::cout Circle with radius c.radius \n; } void visit(Rectangle r) override { std::cout Rectangle r.width x r.height \n; } }; int main() { std::vectorstd::unique_ptrShape shapes; shapes.push_back(std::make_uniqueCircle()); shapes.push_back(std::make_uniqueRectangle()); InfoVisitor info; for (auto s : shapes) { s-accept(info); } return 0; }这段代码可以直接编译运行你只需要根据自己的类型和操作扩展即可。最后说点我的实际心得我用访问者模式踩过的最大一个坑是早期太迷信设计模式硬把一套类型快速膨胀的项目往里套结果把所有人都拖进了改visitor接口的火海。后来我形成了一条个人原则动手写visitor接口之前先花十分钟想想未来半年这个系统里更可能发生变化的是“类型”还是“操作”。类型在变用虚函数 逐步加子类更自然操作在变才轮到访问者登场。如果项目已经用上了C17我更鼓励你先试试std::variant std::visit两条路殊途同归但现代写法的堑壕要浅得多。访问者模式的核心思想——把操作和数据结构解耦、让稳定性的一方成为扩展的支点——这个思想本身比任何一个具体的类图都值钱。你真正需要的不是记住它的写法而是能随时判断出“眼前这堆代码正被哪一侧的变动压得喘不过气”。
返回列表