☰
访问者模式实战复盘:经典双分派与现代C++的std::visit替代方案
2026/10/9 6:54:38 网站建设 项目流程

最近在复盘一个老项目的架构时,又遇到了那个经典的选择题:给一个稳定的对象结构增加新操作,到底是在每个类里塞新方法,还是另起炉灶写一套外部逻辑?当年我用访问者模式(Visitor Pattern)解决过这个问题,这几年接触到现代C++之后,回头再看,发现这个模式在C++里的玩法和从前已经不太一样了。这篇文章就是一次实打实的复盘,从经典写法讲到现代C++的替代方案,中间会穿插一些我在实际项目中踩过的坑和排查过程,希望能给正在啃设计模式或者正在重构代码的你一点参考。

访问者模式能解决的问题,说穿了就一句话:把数据结构和操作解耦。当你的对象结构足够稳定,但操作经常变的时候,用它最合适。常见的典型场景包括语法树遍历、编译器中间表示处理、文档导出、游戏引擎里的碰撞检测分发等等。这篇文章适合已经掌握C++基础语法、了解虚函数和继承,但还没怎么在真实项目里用过设计模式的读者;当然,如果你已经写过几个访问者,也可以看看现代C++里std::visit的实现思路,对比一下两种方案的取舍。

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

1.1 从一次重构说起:当if-else长到爆炸

有段时间我在维护一个表达式计算模块,里面有NumberExpr、AddExpr、MultiplyExpr、VariableExpr这样一堆类型,它们都继承自同一个Expr基类。最初大家图省事,都用一种“类型标签 + switch”的方式处理:

enum class ExprType { Number, Add, Multiply, Variable }; struct Expr { ExprType type; }; double evaluate(const Expr& e) { switch (e.type) { case ExprType::Number: ... case ExprType::Add: ... // 越来越多 } }

一开始只有两种节点,switch写起来很爽。后来节点类型从4个涨到10个,而需要遍历这些节点的操作也从1个涨到4个:计算、打印、类型检查、变量收集。于是代码变成了下面这样:

double evaluate(const Expr& e) { switch (e.type) { /* 10个case */ } } void print(const Expr& e) { switch (e.type) { /* 10个case */ } } void typecheck(const Expr& e) { switch (e.type) { /* 10个case */ } }

问题一下就暴露了:每一处switch都要跟着新类型同步修改。漏改一个case,编译器不一定会给你报错,但运行时就是莫名其妙地走错逻辑。而且这些操作散落在各个文件里,想看清一个类型到底被哪些地方处理了,得全局搜索。

1.2 访问者模式的核心思想

访问者模式的核心思想,是把你的操作封装成独立的类,这些类作为“访问者”去遍历“被访问者”的结构。被访问者的类层次保持稳定,只要提供统一的accept入口,访问者就可以在外部自由扩展新功能。

用上面的例子来套:

  • Expr的每个子类都实现accept(Visitor&)。
  • Visitor是一个接口,里面声明了针对每个具体类型的visit重载。
  • 当你想增加一个新操作时,不需要改任何Expr子类的代码,只需要新写一个实现Visitor接口的类就行。

这就是访问者模式带来的核心优势:操作扩展符合“开闭原则”,对修改关闭,对扩展开放。

1.3 为什么选“双分派”这条路

要理解访问者模式,关键在于“双分派”(Double Dispatch)这个概念。

普通虚函数是单分派:运行时根据对象的动态类型,决定调用哪个虚函数实现。它只考虑了一个对象的实际类型。而在访问者模式里,我们要调用的函数同时取决于两个对象的实际类型:一个是传入的Expr子类,一个是传入的Visitor子类。

访问者模式绕过了语言层面的这个限制。它的做法是:先通过expr->accept(visitor)完成第一层分派(根据Expr的动态类型,进入对应的accept实现),然后在accept内部再调用visitor->visit(*this)完成第二层分派(此时*this的静态类型已经确定,能精确匹配到visitor的对应visit重载)。两个分派叠加,就实现了“双分派”。

2. 经典实现:手写一个可用的C++访问者框架

2.1 稳定数据结构:形状系统

先从一个非常经典的教学案例开始:形状系统。

假设我们有三种形状:圆形、矩形、直角梯形。这套类层次结构非常稳定,可以说十年八年都不会加新形状。但是对形状的操作经常变:要算面积、要画出来、要导出成JSON、要计算包围盒、要序列化……这正是访问者模式的理想场景。

先定义类层次:

#include <iostream> #include <memory> #include <vector> // 前置声明 class Circle; class Rectangle; class RightTrapezoid; // 访问者基类 class ShapeVisitor { public: virtual ~ShapeVisitor() = default; virtual void visit(const Circle& c) = 0; virtual void visit(const Rectangle& r) = 0; virtual void visit(const RightTrapezoid& t) = 0; };

注意这里用了前置声明加传引用的写法。visit接收常量引用,这保证了访问者不会意外修改被访问对象的状态。如果你确实需要修改,也可以用非常量引用,在下面的接口里去掉const就行。

2.2 访问者接口与具体访问者

接着定义形状基类和各子类:

class Shape { public: virtual ~Shape() = default; virtual void accept(ShapeVisitor& v) const = 0; }; class Circle : public Shape { public: explicit Circle(double r) : radius(r) {} void accept(ShapeVisitor& v) const override { v.visit(*this); } double radius; }; class Rectangle : public Shape { public: Rectangle(double w, double h) : width(w), height(h) {} void accept(ShapeVisitor& v) const override { v.visit(*this); } double width; double height; }; class RightTrapezoid : public Shape { public: RightTrapezoid(double top, double bottom, double h) : topBase(top), bottomBase(bottom), height(h) {} void accept(ShapeVisitor& v) const override { v.visit(*this); } double topBase; double bottomBase; double height; };

这里有一个很关键的细节:accept函数是const成员函数,并且visit接收的是const引用。访问者模式在遍历过程中不应该修改对象结构,所以统一用const限定,可以防止在很多地方不小心写错。

2.3 数据结构侧的Accept转发

有了基类和子类之后,接下来写一个具体的访问者。比如计算总面积的访问者可以这样写:

class AreaVisitor : public ShapeVisitor { public: void visit(const Circle& c) override { areaSum += 3.14159265358979 * c.radius * c.radius; ++count; } void visit(const Rectangle& r) override { areaSum += r.width * r.height; ++count; } void visit(const RightTrapezoid& t) override { areaSum += (t.topBase + t.bottomBase) * t.height / 2.0; ++count; } double areaSum = 0.0; int count = 0; };

然后在主程序里这样使用:

int main() { std::vector<std::unique_ptr<Shape>> shapes; shapes.emplace_back(std::make_unique<Circle>(2.0)); shapes.emplace_back(std::make_unique<Rectangle>(3.0, 4.0)); shapes.emplace_back(std::make_unique<RightTrapezoid>(2.0, 4.0, 3.0)); AreaVisitor av; for (const auto& s : shapes) { s->accept(av); } std::cout << "形状数量: " << av.count << "\n"; std::cout << "总面积: " << av.areaSum << "\n"; return 0; }

运行结果:

形状数量: 3 总面积: 34.5664

这里严谨来说,计算233的注释没写,但这个案例足够清晰了。

2.4 完整示例:导出到不同格式

面积访问者只是最简单的案例。如果你面临的是真正“操作多变”的场景,你会发现新写一个访问者类的成本极低。我们再看一个稍微复杂点的导出需求:同一组形状,分别导出成文本、JSON、HTML。

class TextExportVisitor : public ShapeVisitor { public: void visit(const Circle& c) override { out << "Circle(radius=" << c.radius << ")"; } void visit(const Rectangle& r) override { out << "Rectangle(" << r.width << "x" << r.height << ")"; } void visit(const RightTrapezoid& t) override { out << "Trapezoid(" << t.topBase << "+" << t.bottomBase << ",h=" << t.height << ")"; } std::ostream& out; // 外部传入输出流 }; class JsonExportVisitor : public ShapeVisitor { public: void visit(const Circle& c) override { if (!first) out << ","; out << "{\"type\":\"circle\",\"radius\":" << c.radius << "}"; first = false; } void visit(const Rectangle& r) override { if (!first) out << ","; out << "{\"type\":\"rectangle\",\"width\":" << r.width << ",\"height\":" << r.height << "}"; first = false; } void visit(const RightTrapezoid& t) override { if (!first) out << ","; out << "{\"type\":\"trapezoid\",\"top\":" << t.topBase << ",\"bottom\":" << t.bottomBase << ",\"height\":" << t.height << "}"; first = false; } std::ostream& out; bool first = true; };

TextExportVisitor和JsonExportVisitor各自只做一件事,互不干扰。想支持新格式,不需要动形状类,只需新写一个访问者。这是访问者模式最典型、也最舒服的使用方式。

3. 双分派的实现机制与性能开销

3.1 虚函数调用链分析

访问者模式的运行时开销,本质上就是两次虚函数调用:accept一次,visit一次。虚函数调用本身在现代CPU上通常只是多几条指令的事,对绝大多数应用来说性能不是瓶颈。

但是这里有个放大效应:如果你的对象图很大,比如几万个AST节点,那么每个节点都要做两次虚函数调用。如果你的访问操作本身非常轻量(比如只是数数),那么虚函数调用的相对开销就会变得很明显。我在一个代码分析工具里做过一次简单benchmark,遍历10万元素时,访问器模式比直接switch慢大概10%~20%。这个差距可以接受,但不能忽略。

// 简单的计时对比伪代码 auto start = std::chrono::high_resolution_clock::now(); for (int i = 0; i < 1000000; ++i) { shape->accept(visitor); } auto end = std::chrono::high_resolution_clock::now();

如果对性能特别敏感,可以改用下面的策略:

  • 把accept标记为final,减少devirtualize的概率。
  • 在热循环中尽量让访问者对象类型的作者与调用点在同一个编译单元内,方便编译器做devirtualize优化。
  • 如果操作确实极轻,且类型层次稳定,可以考虑使用std::variant方案,后面会展开讲。

3.2 动态类型与静态类型的博弈

访问者模式的核心是把动态类型信息转移到虚函数调用上。当accept调用v.visit(*this)时,*this的静态类型是确定的,这极大地帮助了编译器进行编译期重载决议。动态分派只发生一次(accept),第二次分派其实是静态绑定。

理解这一点可以帮助你排查问题:如果发现某个visit重载没有被正确调用,多半是*this的静态类型不够“具体”。举个例子,如果你在基类里写了:

void accept(ShapeVisitor& v) const override { v.visit(*this); // *this是const Shape&,调用了visit(const Shape&)而不是visit(const Circle&) }

这里因为覆盖写错了,就是调用了visit(const Shape&),但那个函数不存在,编译直接出错。其实编译期能帮你抓到这类问题,所以这个错误相对好查。

3.3 访问者模式与switch的对比

拿访问者模式和传统的switch对比一下:

维度switch + 类型标签访问者模式
新增操作每个switch都要同步改,容易漏只需新增一个访问者类
新增类型每个switch都要加case所有访问者都要加visit重载
类型安全弱,case漏了可能静默失败强,漏了visit重载编译期报错
代码分布逻辑散在各处集中在访问者类内部
性能单次分派,更快双分派,略慢

从表格可以看出,访问者模式的优势集中在“新增操作”这一侧。所以适用场景很明确:类型稳定,操作不稳定。反过来,如果你经常新增类型,那用访问者模式就是给自己挖坑——每加一个类型,所有访问者都要跟着改一遍。

4. 现代C++下的访问者模式新玩法

4.1 std::variant与std::visit:劝退传统写法?

C++17带来的std::variant,在很多场景下几乎可以取代“继承 + 虚函数”这一整套方案。配合std::visit,代码写起来比传统访问者模式简洁得多。

std::variant本质上是一个类型安全的“联合体”,它可以存储几种固定类型中的某一种。std::visit则是一个编译期生成的访问器,它根据variant当前实际存储的类型,自动调用对应的lambda或函数对象。

用上文形状系统重写,变成这样:

#include <variant> #include <vector> struct Circle { double radius; }; struct Rectangle { double width; double height; }; struct RightTrapezoid { double topBase; double bottomBase; double height; }; using Shape = std::variant<Circle, Rectangle, RightTrapezoid>; int main() { std::vector<Shape> shapes; shapes.push_back(Circle{2.0}); shapes.push_back(Rectangle{3.0, 4.0}); shapes.push_back(RightTrapezoid{2.0, 4.0, 3.0}); double areaSum = 0.0; int count = 0; for (const auto& s : shapes) { std::visit([&](const auto& shape) { using T = std::decay_t<decltype(shape)>; if constexpr (std::is_same_v<T, Circle>) { areaSum += 3.14159 * shape.radius * shape.radius; } else if constexpr (std::is_same_v<T, Rectangle>) { areaSum += shape.width * shape.height; } else if constexpr (std::is_same_v<T, RightTrapezoid>) { areaSum += (shape.topBase + shape.bottomBase) * shape.height / 2.0; } ++count; }, s); } std::cout << "总面积: " << areaSum << "\n"; std::cout << "数量: " << count << "\n"; }

注意一点:这里用的是泛型lambda加if constexpr,这是std::visit的常见用法。不过如果你对所有类型的操作逻辑都一样(比如只是打印名字),甚至可以直接写std::visit([](const auto& shape) { /* 通用逻辑 */ }, s);,编译器会自动为每种类型生成一份实例化。

4.2 std::visit的实现原理

std::visit的实现原理,本质上就是把类型分发从运行时搬到了编译期。编译器会生成一张虚函数表,但这里面没有虚函数——它是通过生成一系列函数指针来实现的。

简化版的实现思路是这样的:

template<typename Visitor, typename... Variants> decltype(auto) visit(Visitor&& vis, Variants&&... vars) { // 根据每个variant的index,组合出一个索引 // 用这个索引从一张表里查出对应的函数指针 }

每个std::variant内部都有一个index()方法,告诉你它当前存了第几种类型。std::visit会根据所有variant的index组合,在编译期预生成一张分派表,运行时只需要查表调用。这张表通常是个多维数组,维数等于variant的数量。

从性能来说,std::visit通常比手工的访问者模式更快,因为它只做了一次间接跳转(查表),而访问者模式是两次虚函数调用。而且因为没有虚函数,编译器的优化发挥空间更大,很多情况下可以内联。

4.3 用std::visit重写形状系统

上面的重写已经展示了基本形态。如果进一步封装,可以做得更接近传统访问者模式的手感:

template<typename... Ts> struct Visitor : Ts... { using Ts::operator()...; }; template<typename... Ts> Visitor(Ts...) -> Visitor<Ts...>; int main() { std::vector<Shape> shapes = { Circle{hi 2.0}, Rectangle{3.0, 4.0}, RightTrapezoid{2.0, 4.0, 3.0} };

不过更常见的是一组普通lambda:

for (const auto& s : shapes) { std::visit(Visitor{ [](const Circle& c) { std::cout << "Circle(" << c.radius << ")\n"; }, [](const Rectangle& r) { std::cout << "Rectangle(" << r.width << "x" << r.height << ")\n"; }, [](const RightTrapezoid& t) { std::cout << "Trapezoid(...)\n"; } }, s); }

这样写每个类型都有对应的lambda,编译器会在std::visit实例化时检查是否涵盖了所有类型。如果漏了一种类型,编译会直接报错,不会拖到运行期才崩溃。这个编译期检查是非常宝贵的。

我自己实际使用的经验是:如果类型是封闭的、在同一个模块内,且数量不大(几种到十几种),优先用std::variant+std::visit。如果类型是开放继承体系、可能被外部模块扩展,那么传统访问者模式仍然不可替代。

5. 实战案例:AST遍历与代码生成

5.1 表达式树建模

下面我们用一个更贴近真实项目的案例:表达式树的求值与打印。这里顺便展示一下传统访问者模式在复杂对象图里是怎么工作的。

先定义节点类型:

class Expr { public: virtual ~Expr() = default; virtual void accept(ExprVisitor& v) const = 0; }; class NumberExpr : public Expr { public: explicit NumberExpr(double val) : value(val) {} void accept(ExprVisitor& v) const override { v.visit(*this); } double value; }; class AddExpr : public Expr { public: AddExpr(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : left(std::move(l)), right(std::move(r)) {} void accept(ExprVisitor& v) const override { v.visit(*this); } std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; }; class VariableExpr : public Expr { public: explicit VariableExpr(std::string name) : varName(std::move(name)) {} void accept(ExprVisitor& v) const override { v.visit(*this); } std::string varName; };

再加一个乘法节点:

class MultiplyExpr : public Expr { public: MultiplyExpr(std::unique_ptr<Expr> l, std::unique_ptr<Expr> r) : left(std::move(l)), right(std::move(r)) {} void accept(ExprVisitor& v) const override { v.visit(*this); } std::unique_ptr<Expr> left; std::unique_ptr<Expr> right; };

5.2 求值器与打印器的实现

访问者基类:

class ExprVisitor { public: virtual ~ExprVisitor() = default; virtual void visit(const NumberExpr& e) = 0; virtual void visit(const AddExpr& e) = 0; virtual void visit(const MultiplyExpr& e) = 0; virtual void visit(const VariableExpr& e) = 0; };

求值器实现:

class Evaluator : public ExprVisitor { public: double result = 0.0; std::unordered_map<std::string, double> env; // 变量环境 void visit(const NumberExpr& e) override { result = e.value; } void visit(const VariableExpr& e) override { auto it = env.find(e.varName); if (it != env.end()) { result = it->second; } else { throw std::runtime_error("未定义变量: " + e.varName); } } void visit(const AddExpr& e) override { e.left->accept(*this); double leftVal = result; e.right->accept(*this); result += leftVal; } void visit(const MultiplyExpr& e) override { e.left->accept(*this); double leftVal = result; e.right->accept(*this); result *= leftVal; } };

打印器就更直观了:

class Printer : public ExprVisitor { public: std::string out; void visit(const NumberExpr& e) override { out += std::to_string(e.value); } void visit(const VariableExpr& e) override { out += e.varName; } void visit(const AddExpr& e) override { out += "("; e.left->accept(*this); out += "+"; e.right->accept(*this); out += ")"; } void visit(const MultiplyExpr& e) override { out += "("; e.left->accept(*this); out += "*"; e.right->accept(*this); out += ")"; } };

主程序里这样用:

int main() { auto expr = std::make_unique<AddExpr>( std::make_unique<MultiplyExpr>( std::make_unique<NumberExpr>(3), std::make_unique<VariableExpr>("x") ), std::make_unique<NumberExpr>(5) ); Evaluator eval; eval.env = {{"x", 4}}; expr->accept(eval); std::cout << "结果: " << eval.result << "\n"; // 3*4+5 = 17 Printer p; expr->accept(p); std::cout << "表达式: " << p.out << "\n"; return 0; }

5.3 新增节点类型的痛苦与应对策略

到这里,传统访问者的“甜蜜期”已经体现得差不多了。但你有没有想过,如果表达式系统新增一种节点,比如PowExpr,会发生什么?

答案是:所有继承自ExprVisitor的类都必须新增一个visit(const PowExpr&)重载。如果漏了,编译期就会报错——因为基类的纯虚函数没被实现。这在某些团队里会被当作“麻烦”,但我个人认为这其实是访问者模式最好的保护机制之一。编译期报错远比运行时行为错误强。

现实项目里,如果你维护的是一个有几十种节点的编译器IR,每个访问者类为了应对新节点而加一个方法,确实会有点烦。这时可以给访问者基类提供默认实现:

class ExprVisitor { public: virtual ~ExprVisitor() = default; virtual void visit(const NumberExpr& e) = 0; virtual void visit(const AddExpr& e) = 0; virtual void visit(const MultiplyExpr& e) = 0; virtual void visit(const VariableExpr& e) = 0; // 新节点默认不做处理 virtual void visit(const PowExpr& e) { /* 默认忽略 */ } };

这样新增节点类型时,已有的访问者不会编译失败,它们会静默忽略新节点。但这也带来了一个风险:如果某个访问者本应该处理PowExpr但忘了实现,就会出现静默错误。我们项目的做法是:在默认实现里记录一条警告日志,帮助快速定位。这样既保住了灵活性,又保留了可观测性。

6. 常见坑位与排查心得

6.1 忘了加accept转发导致跑不起来

这个坑最常见。明明写了acceptor接口,子类也override了,但调用的时候发现visit没触发。很多时候就是因为accept里的转发写成了调用基类版本,或者不小心把v.visit(*this)写在了一个非虚函数里。

我的排查习惯是:先在accept里加一行日志,确认它有没有被调用;再在visit里加日志,确认分派是否正确。两步一比对,基本能定位问题。如果是复杂继承链,用调试器在accept上打断点会比日志更高效。

6.2 返回值怎么传递

访问者模式里visit函数通常返回void,但实际业务里经常需要返回值。常见做法有三种:

  1. 在访问者里保存状态(比如前面Evaluator的result成员),访问结束后读取。
  2. visit返回某个固定类型的值。
  3. 使用模板访问者,让visit返回std::any。

方案1最常用,也最符合访问者模式“一次遍历取多个结果”的特点。方案2的问题在于不同的visit可能返回不同类型,很难统一。方案3我在一个动态类型系统里试过,灵活但性能不好,而且std::any用起来有点啰嗦。

我的建议是:优先用保存状态的方式。访问者可以包含多个结果字段,一次遍历收集所有需要的信息,这比反复传参干净得多。

6.3 const与引用陷阱

接visit(const T&)的时候,如果访问者里需要缓存引用或者修改对象,一定要想清楚生命周期。我曾经在遍历AST时把visit(const Node&)里的Node&存到了一个容器里,遍历结束后容器里的引用全部悬垂,调试了半天才发现是生命周期问题。

另一个小坑:如果你在visit里接收的是值而不是引用(比如void visit(Circle c)),拷贝开销可能很大,而且会切断对象身份的关联。务必使用引用,并根据语义选择const或者非const。

6.4 std::visit的编译期检查优势

在使用std::visit时,漏掉某个类型分支通常在编译期就能暴露出来——编译器会告诉你没有匹配的operator()。但如果你用的是泛型lambda加if constexpr,编译器不会强制覆盖所有类型,因为它不知道你要处理哪些类型。这时漏掉一个分支,可能默认进入else分支或者直接静默跳过。

针对这种情况,我建议在if constexpr的链尾加一个static_assert依赖一个always-false的类型:

template<typename T> struct always_false : std::false_type {}; if constexpr (std::is_same_v<T, Circle>) { // ... } else if constexpr (std::is_same_v<T, Rectangle>) { // ... } else { static_assert(always_false<T>::value, "未处理的类型"); }

这样如果将来给Shape加了一种新类型,而所有std::visit的地方没有同步更新,编译期就会直接失败——这正是我们要的效果。

7. 最后的一些个人体会

访问者模式是我在大学学过、工作三四年后才真正用明白的一个模式。最初我觉得它“绕”,写了一大堆接口和虚函数,就是为了把一个switch换个姿势。直到我面对一个真正需要同时支持多种遍历操作、且节点类型相对稳定的AST时,才发现访问者模式带来的扩展性有多舒服。你再也不用跟着需求在十几个类里来回改代码,而是每次新增需求时,新写一个类,编译通过,完事。

现代C++给出的std::variant + std::visit方案,在处理封闭类型集合时几乎是无脑更优解,代码量少、编译期检查强、性能还好。但它替代不了开放继承体系里的传统访问者。说白了,这两种工具不是对错关系,而是适用范围不同。

如果你正在用C++写编译器、解释器、UI控件树、文档模型这类结构稳定但操作多变的系统,建议花点时间吃透访问者模式。如果你所在的领域经常往类型体系里加新类型,那还是老老实实考虑用模板、重载或者std::visit加默认分支来处理。

就我个人的经验来说,设计模式不是背几个UML图那么回事。多在一两个真实项目里用崩溃了、改烦了、再回头重新审视,才会真正理解每个模式它解决的是哪一种痛。访问者模式这课,我算是补上了。

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

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

立即咨询