多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C++访问者模式实战:从双分派到std::visit的演进

C++访问者模式实战:从双分派到std::visit的演进 要聊C里的访问者模式最好从一次真实的代码评审现场切入。当时我负责一个图形编辑器模块里面有一组形状类Circle、Rectangle、CompoundShape全都继承自Shape。产品那边提了个需求要把所有形状导出成SVG。第一反应是什么当然是在基类里加一个虚函数toSvg()每个子类override一遍收工。可半个月后需求又来了导出JSON、统计面积、检查边界尺寸、序列化做Undo快照。每次新操作都要往Shape类层级里塞一个虚函数基类慢慢膨胀成什么都做的“上帝类”而所有子类也要跟着一起改。那段时间我只要看到Shape的定义就头疼。后来换成了访问者模式这类问题才算真正解决。这篇文章不打算讲教科书那套定义我只想聊明白三件事访问者模式到底在解决什么、C里它是怎么高效运作的、以及我实际写代码时踩过的坑和现在更推荐的写法。1. 为什么需要访问者模式——一个导出功能引发的思考1.1 频繁给类层级加虚函数是慢性毒药假设你现在有下面三个形状类class Shape { public: virtual ~Shape() default; }; class Circle : public Shape { public: double radius 0.0; }; class Rectangle : public Shape { public: double width 0.0; double height 0.0; }; class CompoundShape : public Shape { public: std::vectorstd::unique_ptrShape children; };需求来了导出SVG。常规做法是在Shape里加virtual std::string toSvg() const 0;然后三个子类各自实现一遍。这段代码很快能跑测试也能过——但问题在于这种“每个操作都进基类”的写法没法长久。过了两周产品又要求导出JSON。你会再往基类加一个virtual std::string toJson() const 0;。又过了两周要求统计所有子形状的总面积。再加一个virtual double area() const 0;。你会发现这个基类维护成本越来越高子类也越来越臃肿。更关键的是你每加一个功能就要把整个类层级的所有源文件都打开、改一遍、重新编译。哪怕你只是列一个virtual void draw() 0;管理所有派生类也是沉重的负担。1.2 访问者模式真正解决的是“操作膨胀”问题访问者模式的核心想法特别朴素把“数据结构”和“作用在数据结构上的操作”拆开。怎么拆让每个数据类只暴露一个能让你带着“外部操作”进来的入口也就是一颗“钉子”——这个入口就是accept函数。然后你的所有外部操作比如 ExportSvgVisitor、ExportJsonVisitor、AreaCalculatorVisitor都写成一个独立的类也叫访问者里面针对每一种具体数据类型重载一个visit方法。于是你再也不需要在Shape类层级里加任何虚函数了。新加一个功能就是新写一个Visitor类其他已有的类一概不动。这就把“加一个操作需要改全层级”变成了“加一个操作只改一个类”。但是这里有个细节值得琢磨在Java或者C#里访问者模式常配合“重载overload”加“动态绑定dynamic dispatch”实现而在C里由于重载决议是编译期静态完成的很多事情看起来一样踩坑的地方却完全不同。接下来看看C的经典实现。2. 经典实现拆解双分派机制是访问者模式的灵魂2.1 一个最小可用骨架延续上面的形状例子先定义访问者基类接口// 前置声明 class Circle; class Rectangle; class CompoundShape; class ShapeVisitor { public: virtual ~ShapeVisitor() default; virtual void visit(Circle c) 0; virtual void visit(Rectangle r) 0; virtual void visit(CompoundShape cs) 0; };然后在每个具体类里实现acceptclass Shape { public: virtual ~Shape() default; virtual void accept(ShapeVisitor v) 0; }; class Circle : public Shape { public: double radius 0.0; void accept(ShapeVisitor v) override { v.visit(*this); // 这里的 *this 是 Circle } }; class Rectangle : public Shape { public: double width 0.0; double height 0.0; void accept(ShapeVisitor v) override { v.visit(*this); // 这里的 *this 是 Rectangle } };注意CompoundShape的accept要多做一件事把访问者转发给所有子节点。class CompoundShape : public Shape { public: std::vectorstd::unique_ptrShape children; void accept(ShapeVisitor v) override { for (auto child : children) { child-accept(v); } v.visit(*this); } };接着写一个具体访问者比如计算面积class AreaCalculator : public ShapeVisitor { public: double totalArea 0.0; void visit(Circle c) override { totalArea 3.141592653589793 * c.radius * c.radius; } void visit(Rectangle r) override { totalArea r.width * r.height; } void visit(CompoundShape cs) override { // 子节点在 accept 中已经递归访问过了 // 这里不额外累加因为组合节点本身没有直接面积 // 如果组合节点有自身面积就写在这里 } };调用方式长这样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})); AreaCalculator areaCalc; for (auto shape : shapes) { shape-accept(areaCalc); } std::cout areaCalc.totalArea std::endl; // 输出 24.566...2.2 两次虚表调度到底发生了什么很多人第一次看到v.visit(*this)都会觉得绕既然调用处就在Circle::accept里面那直接调用v.visit(*this)难道不会把自己锁死成访问者基类的调用吗答案是v本身是ShapeVisitor它指向哪个具体访问者类我们不知道可能是AreaCalculator也可能是未来的ExportSvgVisitor。所以第一次虚函数调用发生在visit(*this)上它会根据v的实际类型定位到正确访问者的虚函数表里对应的槽位。那这跟普通的虚函数有什么区别普通的虚函数调用只有一次虚表调度比如shape-toSvg()。访问者模式则有两次外部调用shape-accept(visitor)这是一次虚函数调用直接找到当前shape的动态类型Circle、Rectangle 等进入Circle::accept之后内部执行visitor.visit(*this)这里*this的静态类型已经窄化为Circle编译器能精确选择visit(Circle)这个重载但因为visitor本身是ShapeVisitor所以这又是一次虚函数调用找到真正访问者的visit实现。这也就是所谓的“双分派double dispatch”一个操作的结果同时依赖于两个对象的动态类型——数据对象的类型和访问者的类型。正因为C里普通虚函数只看一个对象的动态类型如果你想达到这种“两个动态类型共同决定行为”的效果就得靠这个accept visit的双层写法来模拟。2.3 带返回值、带递归这些变体在工程里更常用上面的AreaCalculator把累加结果放在自己的成员变量里调用完直接读。这很直观但很多场景下你需要每个visit方法返回一个结果比如把AST节点换算成一个字符串、一个整数或一个ErrorCode。访问者模式在返回类型上有个经典麻烦基类的visit函数签名固定所有派生访问者的返回值必须一致。假如你既想做求值返回double又想做语法树打印返回std::string纯虚接口就容易卡住。工程里的解法通常有三种传引用参数带回结果virtual void visit(Circle c, double outArea) const 0;简单粗暴但不适合链式返回。模板化访问者基类templatetypename R class ShapeVisitor { virtual R visit(Circle) 0; };这种方式能同时支持多个返回类型的访问者但会导致访问者类型分叉。直接让访问者携带状态就像上面的AreaCalculator结果存在访问者对象里。对大多数场景这是最省事的。如果你的数据结构是树状的那CompoundShape::accept里递归调用子节点accept的写法就很关键。它让“遍历树”这个公共控制流写在数据结构自己身上访问者只需要关心“到了某个节点该干什么”。正因为这一点访问者模式在AST解析器、表达式求值器、文件系统遍历这些场景里出镜率极高。3. 实际开发中常见的三个坑3.1 accept 里的重载决议陷阱这是我在实际代码里遇到最多的问题而且坑起来真的很隐蔽。你可能会以为下面这段代码“等价”class Circle : public Shape { public: void accept(ShapeVisitor v) override { Shape base *this; // 不小心把 *this 提升为 Shape v.visit(base); // 编译错误没有 visit(Shape) } };一旦你把*this赋值给Shape重载决议在编译期就会基于这个Shape的静态类型寻找visit函数。如果ShapeVisitor正好没有visit(Shape)直接编译失败如果不幸它有一个带默认实现的visit(Shape)那你的访问者永远访问不到派生类的新逻辑运行结果会静默错误这才是最要命的。原因本质上是C的重载决议发生在编译期编译器只看表达式的静态类型。*this在Circle::accept成员函数内部静态类型是Circle一旦你把它转换成基类引用编译器就不再记得它可能是一个Circle了。所以我的铁律是accept里面必须直接写v.visit(*this)不要经过任何中间引用或指针转换。就算IDE抽风给了重命名建议你也得人工盯住这一点。3.2 const 正确性访问者的引用参数怎么设计再谈一个几个月前我在代码评审里跟同事来回拉扯的问题访问者的重载参数到底该用Circle还是const Circle如果你写的是只做只读操作的访问者比如NodePrinter、JsonExporter用const Circle是更严谨的能防止访问者内部意外修数据。但问题马上来了你的accept本身通常是void accept(ShapeVisitor v)不修饰为 const。如果一个const Shape对象想被访问它的accept没法调用带非const参数的访问者。工程经验是分成两种情况处理只读访问者把accept声明为void accept(ShapeVisitor v) const那么Circle::accept内部v.visit(*this)中的*this就是const Circle访问者接口里对应写void visit(const Circle) const。修改类访问者accept不带 const访问者接口用void visit(Circle)。最需要避免的是同时在访问者里写两个重载visit(Circle)和visit(const Circle)。看着方便但遇到一些边缘场景时模板推导会优先匹配非const版本导致你预期的调度顺序被打乱。要修就统一设计成一种。我个人现在的习惯是统一使用accept(Visitor v)加非const的visit(T)因为更多场景计算、修改、移动都需要可变访问。如果你有几个纯只读的Visitor就让它们自己在内部不写数据好了不做强制。3.3 新增类型时开闭原则的代价比你想象的大访问者模式常常被夸成“完美符合开闭原则对扩展开放对修改封闭”。这话只说对了一半。它符合的“开闭”是新增操作访问者时既有类型不需要改但新增类型时所有访问者基类接口和每个已有访问者都要跟着动。比如你给形状系统新增一个Triangle类那么class ShapeVisitor { public: virtual void visit(Circle c) 0; virtual void visit(Rectangle r) 0; virtual void visit(Triangle t) 0; // 新增 ... };这看起来是纯虚函数新增一个但每个已经写好的AreaCalculator、ExportSvgVisitor、DebugPrintVisitor全都必须实现一次visit(Triangle)否则编译失败。这在C里恰恰是个“特性”它用编译错误提醒你新增类型已经被老逻辑遗漏了。另一个选择是把ShapeVisitor里所有visit写成带默认空实现的虚函数实现“开着”的访问者。但那样代价更大以后新增类型时旧访问者会静默漏处理这个新类型极易产生线上Bug。所以说白了如果你的项目里“类型的增长速度”明显高于“操作的增长速度”那就别死磕访问者模式。后面我会讲更贴合这种场景的现代替代方案。4. C17给访问者模式带来的新写法std::visit4.1 用 std::variant 代替继承层级很多朋友一看访问者模式要写基类、子类、虚函数、accept、visit好大一圈模板代码就劝退了。其实对于能自包含在值语义里的数据结构C17 的std::variant加std::visit就是访问者模式的现代简化版而且写起来干净得多。我们把形状定义成class Circle { public: double radius 0.0; }; class Rectangle { public: double width 0.0; double height 0.0; }; using ShapeVar std::variantCircle, Rectangle;那么“求面积”就是一段独立的逻辑根本不需要定义什么Visitor基类double area(const ShapeVar s) { return std::visit([](const auto shape) - double { using T std::decay_tdecltype(shape); if constexpr (std::is_same_vT, Circle) { return 3.141592653589793 * shape.radius * shape.radius; } else { return shape.width * shape.height; } }, s); }这里std::visit自己就完成了“分发”动作lambda 内部的if constexpr充当了编译期类型选择。整个流程没有虚函数没有accept没有手工重载。4.2 overloaded lambda 一行解决多类型分派上面的写法适合类型少、逻辑简单的场景。如果类型多点一个lambda里塞一堆if constexpr会很丑。C17 里有个很经典的overloaded辅助结构配合std::visit用起来非常顺手templateclass... Ts struct overloaded : Ts... { using Ts::operator()...; }; templateclass... Ts overloaded(Ts...) - overloadedTs...;然后std::visit( overloaded{ [](Circle c) { c.radius * 2.0; }, [](Rectangle r) { r.width * 2.0; r.height * 2.0; } }, shapeVar );你看这本质上跟ShapeVisitor基类里的visit(Circle)、visit(Rectangle)完全一一对应只是由编译器帮你生成了调度的跳转表。以前“新增一个操作要写一个新Visitor派生类”现在“新增一个操作就是写一段新的std::visit调用”。直观、短小、局部化。我把经典手写访问者和std::visit的路数放在一起做个对比方便你按场景选型对比维度经典继承式访问者std::variant std::visit分发机制两次虚表调度accept visit编译期生成跳转表运行期一次索引数据生命周期支持多态对象图可长期持有、可共享值语义适合短期局部计算、复制成本敏感场景新增操作新增一个 Visitor 子类新增一个 std::visit 调用新增类型所有 visitor 基类、实现全部要动variant 类型一变旧 std::visit 编译失败性能两次虚调用现代CPU分支预测优秀但不够极致跳转表 类型索引通常非常快适合高频调用扩展性适合复杂继承结构、无法全局改类型定义的场景要求所有候选项在设计期都能确定并集中表达4.3 我的选型判断标准做了几年C我的选型逻辑渐渐固定成一种直觉如果我的数据体系本身是一个天然的继承结构里面有共享状态、有虚析构、有基类指针穿梭在多个模块之间那我会老老实实用经典访问者模式。因为这种项目里强行改用std::variant往往要牵动架构根基。反过来如果这组类型只是相对独立的几个数据结构彼此没有公共基类逻辑生命周期短那我绝对选std::variantstd::visit。它少写好多代码还不容易在重载决议和 const 正确性上翻车。顺带说一句C20/23 时代std::visit也不是没有限制。它的分发表是编译期构建的对运行时频率极高的调用代码体积和 cache 压力是个隐性成本。极端性能场景还是要用if constexpr或者手写 switch 来逼近极致不过这是小概率需求了90% 的项目不需要纠结这一点。5. 实战复盘我写表达式求值器的完整决策过程5.1 问题定义与初始方案假设我们要做一个简单的表达式引擎支持的语法是整数常量、二元加减乘除。最容易想到的数据结构是下面这个class AstNode { public: virtual ~AstNode() default; virtual double eval() const 0; }; class NumberNode : public AstNode { public: double value 0.0; double eval() const override { return value; } }; class BinaryNode : public AstNode { public: char op ; std::unique_ptrAstNode lhs; std::unique_ptrAstNode rhs; double eval() const override { double l lhs-eval(); double r rhs-eval(); switch (op) { case : return l r; case -: return l - r; case *: return l * r; case /: return l / r; default: return 0.0; } } };这个方案能跑但随着需求越来越多问题开始显现。产品要求“打印中缀表达式”你要不要给AstNode再加一个std::string toInfix() const虚函数要求“表达式类型检查”又加一个TypeCheckResult typeCheck() const没过多久这个基类就会变成一个塞满所有业务逻辑的“垃圾场”。5.2 换成访问者之后每个功能都是一次独立进补我把eval()从 AST 节点类里挪出去了。节点只负责accept所有操作都变成访问者类class AstVisitor { public: virtual ~AstVisitor() default; virtual void visit(NumberNode node) 0; virtual void visit(BinaryNode node) 0; }; class AstNode { public: virtual ~AstNode() default; virtual void accept(AstVisitor v) 0; }; class NumberNode : public AstNode { public: double value 0.0; void accept(AstVisitor v) override { v.visit(*this); } }; class BinaryNode : public AstNode { public: char op ; std::unique_ptrAstNode lhs; std::unique_ptrAstNode rhs; void accept(AstVisitor v) override { v.visit(*this); } };然后求值器可以写成这样这里的遍历顺序自己掌控完全不用把左子树右子树的递归逻辑塞回数据类class EvalVisitor : public AstVisitor { std::stackdouble stack_; public: double result() const { if (stack_.empty()) return 0.0; return stack_.top(); } void visit(NumberNode node) override { stack_.push(node.value); } void visit(BinaryNode node) override { // 后序遍历先算左子树再算右子树 node.lhs-accept(*this); node.rhs-accept(*this); double rhs stack_.top(); stack_.pop(); double lhs stack_.top(); stack_.pop(); switch (node.op) { case : stack_.push(lhs rhs); break; case -: stack_.push(lhs - rhs); break; case *: stack_.push(lhs * rhs); break; case /: stack_.push(lhs / rhs); break; default: stack_.push(0.0); break; } } };再写一个打印中缀表达式的访问者class InfixPrinter : public AstVisitor { public: void visit(NumberNode node) override { std::cout node.value; } void visit(BinaryNode node) override { std::cout (; node.lhs-accept(*this); std::cout node.op; node.rhs-accept(*this); std::cout ); } };注意到没有BinaryNode类从头到尾一个字节都没变过。求值和打印这两个操作表面上是“加进类里的函数”实际上完全隔离开了。后面如果想加“把表达式编译成字节码”再写一个CompileVisitor就完事。5.3 什么时候坚决别用访问者模式写到这里我想把反面清单也列出来免得有人照着这篇直接把自己项目的所有虚函数全部搬进Visitor类型集合剧烈膨胀比如插件化系统里第三方随时可能新增自己的节点类型。这种情况应该优先考虑std::variant之外更灵活的注册表调度别碰访问者。操作集合永远不变只有那几个虚函数。那直接用虚函数就是最佳解法访问者只会徒增代码量。比如一个接口只有getId()、getName()没有必要搞Visitor。对代码体积和分支预测极其敏感的底层热路径。虚函数已经有开销了访问者的双重虚调用更是加倍这种场景老老实实用 switch variant或者直接用类型标签做分派。跨语言边界比如要暴露C接口给其他语言调用时访问者模式生成的类结构往往比普通的枚举函数指针更复杂不利于对接。5.4 个人经验谈我从第一次用访问者模式到现在最大的体会是这个模式真正的价值不在于“让代码更短”而在于“让新需求改动范围可预测”。每次新加一种处理逻辑我只需要新建一个Visitor文件把变化集中在一个局部既不用翻旧代码也不会影响已经验证过的逻辑。但也正因为这样它强迫你在设计初期就想清楚“类型集合到底稳不稳定”。我见过太多团队在类层级还是空壳子的时候就套上Visitor最后升级类型时痛苦得要命。反过来也有团队在类型集合稳定、操作频繁增加的场景里死活不用Visitor天天往基类加虚函数最后基类膨胀到没人敢动。访问者模式不是什么银弹它是一种“把未来操作的增长成本前置到类型稳定期”的设计。你愿意前期多写一段框架代码换来后续加操作时的轻装上阵。如果你手头正好有一个类型固定、操作频繁迭代的模块我建议直接用std::variant std::visit试试实在不行再退回经典双分派——你会发现同样的思路现代C写起来是真的舒服。
返回列表