☰
C++访问者模式实战:从双分派到std::visit的演进
2026/10/3 10:32:29 网站建设 项目流程

要聊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::vector<std::unique_ptr<Shape>> 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; };

然后在每个具体类里实现accept:

class 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::vector<std::unique_ptr<Shape>> 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::vector<std::unique_ptr<Shape>> shapes; shapes.push_back(std::make_unique<Circle>(Circle{2.0})); shapes.push_back(std::make_unique<Rectangle>(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()。访问者模式则有两次:

  1. 外部调用shape->accept(visitor),这是一次虚函数调用,直接找到当前shape的动态类型(Circle、Rectangle 等);
  2. 进入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;简单粗暴,但不适合链式返回。
  • 模板化访问者基类:template<typename 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. C++17给访问者模式带来的新写法:std::visit

4.1 用 std::variant 代替继承层级

很多朋友一看访问者模式要写基类、子类、虚函数、accept、visit,好大一圈模板代码,就劝退了。其实对于能自包含在值语义里的数据结构,C++17 的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::variant<Circle, Rectangle>;

那么“求面积”就是一段独立的逻辑,根本不需要定义什么Visitor基类:

double area(const ShapeVar& s) { return std::visit([](const auto& shape) -> double { using T = std::decay_t<decltype(shape)>; if constexpr (std::is_same_v<T, 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会很丑。C++17 里有个很经典的overloaded辅助结构,配合std::visit用起来非常顺手:

template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; }; template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

然后:

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::variant+std::visit。它少写好多代码,还不容易在重载决议和 const 正确性上翻车。

顺带说一句,C++20/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_ptr<AstNode> lhs; std::unique_ptr<AstNode> 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_ptr<AstNode> lhs; std::unique_ptr<AstNode> rhs; void accept(AstVisitor& v) override { v.visit(*this); } };

然后求值器可以写成这样,这里的遍历顺序自己掌控,完全不用把左子树右子树的递归逻辑塞回数据类:

class EvalVisitor : public AstVisitor { std::stack<double> 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++写起来是真的舒服。

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

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

立即咨询