☰
C++访问者模式实战:双重分派、状态管理与std::variant取舍
2026/10/1 12:40:09 网站建设 项目流程

如果你在大型项目里拆过 AST、写过多态事件总线、维护过控件树,应该体会过这种尴尬:对象的类型就那么几个,但对它们要做的事却越来越多。求值、打印、翻译、类型检查、序列化、统计……每加一种操作,就得往每个节点类里塞一个虚函数,改完一编译,十几处地方跟着报错。C++ 里的访问者模式,就是用来解决“类型稳定、操作易变”这个组合问题的。它也是 C++ 面试八股里常年被问到的点,但说实话,大多数教材只会讲一个打印图形的玩具例子,真正工程可用的高级玩法,比如返回值怎么处理、有状态访问者怎么写、递归遍历怎么防爆栈,很少有人说透。这篇文章我从头到尾拆一遍访问者模式在 C++ 里的实战用法,并带一个完整的表达式求值器案例,顺便讲清楚它和现代 C++ 里 std::variant + std::visit 的取舍。内容偏实践,但概念也会讲明白,新手能上手,老手也能对一下自己的姿势。

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

1.1 双重分派:C++ 里最容易被忽略的机制

先说一个很多人没意识到的事实:C++ 的虚函数只做单重分派。也就是说,当你调用一个虚函数时,分派只取决于一个对象的动态类型。比如shape->draw(),到底调用哪个 draw,看的是 shape 指向的具体类型。这在绝大多数场景下够用了,但“够用”不等于“完备”。

假设你有三种节点类型:数字、变量、二元运算。现在要针对这个类型体系实现求值、打印、节点计数三个功能。多态的做法是给每个节点类里加上double eval()、void print()、size_t count()三个虚函数。看起来没什么问题,但如果下一个需求是“检查树里有没有除法”,又要改三个类;再下个需求是“生成某种中间代码”,又要改三个类。类本身没有任何变动,但你每天都要打开它们。这就是操作集合膨胀时,算法和类型耦合带来的直接成本。

访问者模式把问题反过来看。它把“每种节点上能执行的操作”抽象成一组重载函数,放在一个独立的 Visitor 类里。节点本身只保留一个accept(Visitor&)方法,调用时把“自己具体是什么类型”暴露给 Visitor。这样做之后,再加一个新操作,只需要写一个新的 Visitor 类,所有节点类一字不改。

这里面的关键机制是双重分派。第一次分派发生在node.accept(visitor):因为 accept 是虚函数,运行时能定位到具体的节点类型;第二次分派发生在 accept 内部执行visitor.visit(*this):因为*this已经具备具体类型,编译器会选中 Visitor 里对应的重载函数。两个环节缺一不可。你可以这样理解:就像处理一张单据,你得先走到正确的窗口,窗口再把单据递给对口的审核人。第一个动作决定“哪一种单据”,第二个动作决定“哪一种审核逻辑”。C++ 没有原生的 double dispatch 语法,访问者模式就是用两层虚函数把它手搓出来了。

1.2 三个问题判断该不该用

访问者模式不是银弹,用错了反而让代码更绕。我一般用三个问题自我拷问:

  • 类型集合是否相对稳定?如果这个体系隔三差五就要加新类型,比如一个协议里动不动冒出新消息类型,那访问者模式的成本会很高,因为每加一个类型,所有的 Visitor 接口都要加一个纯虚函数,所有实现类都要补。
  • 操作集合是否在持续膨胀?如果这个体系上会不停出现新功能、新算法,比如 AST 要不断加语义检查、字节码生成、死代码分析,访问者模式的收益就会非常大。
  • 对象结构是否以组合方式嵌套?访问者模式在递归组合结构上最顺手。表达式、语法树、控件树都是典型的组合结构,子节点可以继续 accept,遍历逻辑天然和操作逻辑分离。

如果三个问题的答案都是“是”,访问者模式几乎就是最优解。如果类型集合天天变、操作集合又很少动,那不如老老实实用普通虚函数。还有一类情况,类型集合小且封闭,比如就三四种固定的消息类型,我更推荐直接用 std::variant,后面第 5 章会详细对比。

2. C++ 访问者模式的标准骨架与三种实用变体

2.1 经典双分派骨架:代码长什么样

先看最标准的骨架。这里我用表达式节点举例,但要说明的是,这套骨架可以套到任意组合结构上。

// 前置声明,打破循环依赖的关键 struct Number; struct BinaryOp; struct Variable; // 访问者抽象接口 struct AstVisitor { virtual ~AstVisitor() = default; virtual void visit(const Number& node) = 0; virtual void visit(const BinaryOp& node) = 0; virtual void visit(const Variable& node) = 0; }; // 节点基类 struct AstNode { virtual ~AstNode() = default; virtual void accept(AstVisitor& v) const = 0; };

每个具体节点实现 accept,注意 accept 内部不是做业务,而是把自身类型一次性抛给 Visitor:

struct Number : AstNode { double value; explicit Number(double v) : value(v) {} void accept(AstVisitor& v) const override { v.visit(*this); // *this 是 const Number&,精确匹配 visit(const Number&) } }; struct Variable : AstNode { std::string name; void accept(AstVisitor& v) const override { v.visit(*this); } }; struct BinaryOp : AstNode { char op; std::unique_ptr<AstNode> lhs; std::unique_ptr<AstNode> rhs; BinaryOp(char o, std::unique_ptr<AstNode> l, std::unique_ptr<AstNode> r) : op(o), lhs(std::move(l)), rhs(std::move(r)) {} void accept(AstVisitor& v) const override { v.visit(*this); } };

写到这里,访问者接口里的三个纯虚函数已经和节点类形成一一对应的关系。这个对应关系越明确,编译期的保护就越强。如果哪个 Visitor 漏实现了某个节点类型,它自己就无法实例化,编译直接报错。

这里有两个细节我要特别指出来。第一,accept是 const 成员函数,所以节点对象以 const 引用被访问时也能正常工作。第二,visit的重载参数必须和节点类型完全匹配。我在实际项目里经常看到新手把visit(const BinaryOp& node)错写成visit(const AstNode& node),结果编译器选择了接受基类的那个重载,整个双分派链条就断了,运行结果完全错误。这是访问者模式最经典的重载解析陷阱,第 4 章会重点展开。

2.2 变体一:void 返回 + 结果成员

标准骨架里见面都是 void 返回。这不是巧合,而是因为一个 Visitor 要面对多种节点类型,每个节点的 visit 返回值类型要统一。GoF 的访问者模式返回值本来就设计成 void,结果保存在外部。

工程上最朴素的 C++ 做法就是,把“结果”做成 Visitor 的成员变量,外部调用 accept 之后再去取这个成员。下面是一个简化示例:

struct Evaluator : AstVisitor { double result = 0.0; void visit(const Number& node) override { result = node.value; } void visit(const Variable& node) override { // 暂时不做真正的查表,先给个默认值 result = 0.0; } void visit(const BinaryOp& node) override { node.lhs->accept(*this); double lhs = result; node.rhs->accept(*this); double rhs = result; switch (node.op) { case '+': result = lhs + rhs; break; case '-': result = lhs - rhs; break; case '*': result = lhs * rhs; break; case '/': result = lhs / rhs; break; } } };

这个写法很好理解,但要小心一个极其隐蔽的坑:因为同一时刻只有一个 result 成员,递归求值时必须先保存左子树的结果,再让右子树覆盖 result,不然左值会被丢掉。上面代码里我特意写了double lhs = result;这一行,这行在初版很容易被忘掉。忘掉之后的表现也很诡异,同一个表达式1 + 2会算成 2,因为左子树的求值结果被右子树冲掉了。这类问题不用调试器很难看出来,因为它不是崩溃,是“算错”。

2.3 变体二:把状态做成引用参数

有时候你不想在 Visitor 内部藏着结果,希望外部传入一个输出对象。最直接的办法是让 visit 接收额外参数,但接口已经固定成visit(const Number&),不能随便加参数。这时候可以在构造 Visitor 时把输出引用传进去:

struct Printer : AstVisitor { explicit Printer(std::ostream& out) : out_(out) {} void visit(const Number& node) override { out_ << node.value; } void visit(const Variable& node) override { out_ << node.name; } void visit(const BinaryOp& node) override { out_ << '('; node.lhs->accept(*this); out_ << ' ' << node.op << ' '; node.rhs->accept(*this); out_ << ')'; } private: std::ostream& out_; };

这种“把外设/上下文塞进构造函数”的做法,是 C++ 访问者模式里最常见的结构。它有两个好处:一是接口保持统一,二是状态可以跨节点累积。比如你要统计树里有多少个变量,可以直接在 Visitor 里维护一个size_t varCount,每 visit 一个 Variable 就累加。

但引用成员也有生命周期问题。如果 Printer 持有的流对象比 Printer 先析构,那后面任何一次 visit 都是未定义行为。所以我通常建议打印这类临时用途的 Visitor,在栈上创建、就地使用,不要把它存到容器里长期持有。访问者模式里的 Visitor 生命周期越短越好,最好就是一个函数调用里出现一次。

2.4 变体三:模板化返回类型的陷阱与正解

很多人在实际需求里会遇到“visit 要带返回值”的情况,于是想把 Visitor 接口模板化:

template <typename R> struct ValueVisitor { virtual R visit(const Number& node) = 0; virtual R visit(const BinaryOp& node) = 0; virtual R visit(const Variable& node) = 0; };

这个思路本身没错,但要命的是虚函数不能是模板成员,而 accept 又是虚函数。你没法写一个模板化的 accept,让它能适配任意返回类型的 Visitor。也就是说,节点类一旦写出了void accept(AstVisitor&),就锁死了 Visitor 的类型。这就是 C++ 访问者模式“接口统一”和“返回值灵活”之间的结构性矛盾。

解决办法也不是没有。我见过几种:

  • 强制统一返回值类型。比如所有 visit 都返回一个EvalResult,这个类型里放一个std::variant<double, std::string, Error>,求值器和打印器都可以用,只是打印器只关心字符串分支。
  • 用 void 访问者 + 输出参数。前面 Printer 那种方案,外部结果放构造函数引用。
  • 用全局分派函数做适配。放弃 accept 虚函数,改用dynamic_cast链在外部匹配具体类型,让 dispatch 本身变成模板函数,返回值就彻底自由了。

最后一种方案虽然少见,但在某些场景很实用。它把 double dispatch 从“节点主动 accept”改成“外部用 RTTI 判断类型”,代价是每次都要做一串 dynamic_cast,性能差一些,而且新增类型时必须改分派函数。所以除非你确实需要高度泛化的返回值,否则我更推荐第二种:void 访问者 + 引用输出。它不优雅,但最稳。

3. 实战复盘:写一个能扛住扩展的表达式求值器

3.1 需求与设计取舍

接下来拿一个完整例子走一遍。我选表达式系统,因为它是访问者模式最经典的落点:有多个节点类型、节点嵌套、新操作层出不穷。我要做一个极简的表达式系统,支持数字、变量、四则运算这几个节点,然后在同一个节点体系上实现三个操作:求值、打印、统计节点数。

这个例子覆盖了访问者模式几个核心痛点:一是求值需要递归访问子节点,二是打印需要维护上下文(括号、输出流),三是统计操作需要跨节点累积状态。三个操作一个节点体系,完成之后你可以很直观地感受到“加新操作不再动旧代码”是什么体验。

3.2 节点体系与访问者接口实现

节点代码已经在第 2 章给出了,这里直接放到完整文件结构里。我实际写工程代码时的头文件组织一般是这样:

// ast_fwd.h:前置声明 struct Number; struct BinaryOp; struct Variable;
// ast_visitor.h:访问者接口 #pragma once #include "ast_fwd.h" struct AstVisitor { virtual ~AstVisitor() = default; virtual void visit(const Number& node) = 0; virtual void visit(const BinaryOp& node) = 0; virtual void visit(const Variable& node) = 0; };
// ast_node.h:节点定义 #pragma once #include <memory> #include <string> #include "ast_visitor.h" struct AstNode { virtual ~AstNode() = default; virtual void accept(AstVisitor& v) const = 0; }; struct Number : AstNode { double value; explicit Number(double v) : value(v) {} void accept(AstVisitor& v) const override { v.visit(*this); } }; struct Variable : AstNode { std::string name; void accept(AstVisitor& v) const override { v.visit(*this); } }; struct BinaryOp : AstNode { char op; std::unique_ptr<AstNode> lhs; std::unique_ptr<AstNode> rhs; BinaryOp(char o, std::unique_ptr<AstNode> l, std::unique_ptr<AstNode> r) : op(o), lhs(std::move(l)), rhs(std::move(r)) {} void accept(AstVisitor& v) const override { v.visit(*this); } };

头文件组织这一点多说一句:不要让节点头文件反向依赖具体的 visitor 实现,否则工程一大就会出现编译层次混乱。访问者接口头文件只需要前置声明节点类型,就能写出visit(const Number&)这样的虚函数声明;而节点定义因为要实现 accept,需要看到完整的 AstVisitor 定义,所以节点头文件包含访问者接口头文件。别写反了,写反了就是循环依赖地狱。

3.3 三个访问者:求值、打印、统计

真正有意思的部分来了。一个具体的求值器接受变量环境,计算结果存在 result 成员里:

#include <stdexcept> #include <unordered_map> struct Evaluator : AstVisitor { explicit Evaluator(const std::unordered_map<std::string, double>& env) : env_(env) {} double result = 0.0; void visit(const Number& node) override { result = node.value; } void visit(const Variable& node) override { auto it = env_.find(node.name); if (it == env_.end()) throw std::runtime_error("undefined variable: " + node.name); result = it->second; } void visit(const BinaryOp& node) override { node.lhs->accept(*this); const double lhs = result; node.rhs->accept(*this); const double rhs = result; switch (node.op) { case '+': result = lhs + rhs; break; case '-': result = lhs - rhs; break; case '*': result = lhs * rhs; break; case '/': if (rhs == 0.0) throw std::runtime_error("division by zero"); result = lhs / rhs; break; default: throw std::runtime_error("unknown operator"); } } private: const std::unordered_map<std::string, double>& env_; };

求值逻辑很直接,但要再次提醒那个const double lhs = result;的细节。如果你写完发现表达式求值结果不对,十有八九是这里没有保存中间结果,或者把lhs、rhs变量的作用域搞错了。另外,我在visit(const BinaryOp&)里直接抛异常处理“未定义变量”和“除零”。异常穿过递归栈,比返回错误码在语义上清晰很多,尤其是这种深层嵌套表达式,错误码一层层往外传会写死人。

打印访问者用输出流做状态,前面已经展示过。这里再补一个统计节点数的,它演示了“跨节点累积状态”的访问者形态:

struct NodeCounter : AstVisitor { size_t count = 0; void visit(const Number& node) override { (void)node; ++count; } void visit(const Variable& node) override { (void)node; ++count; } void visit(const BinaryOp& node) override { ++count; node.lhs->accept(*this); node.rhs->accept(*this); } };

注意 NodeCounter 在每个节点里都先自增自己的计数,再去递归子节点。它的逻辑和求值器完全不同,但节点的 accept 一行没动。这就是访问者模式的核心红利:操作之间的差异被隔离在各自的类里,节点体系本身就是稳定的。

使用起来就是一个栈上的临时 Visitor:

int main() { // 构造表达式: (1 + 2) * (x - 3) auto expr = std::make_unique<BinaryOp>( '*', std::make_unique<BinaryOp>('+', std::make_unique<Number>(1.0), std::make_unique<Number>(2.0)), std::make_unique<BinaryOp>('-', std::make_unique<Variable>("x"), std::make_unique<Number>(3.0)) ); Printer printer(std::cout); expr->accept(printer); // 输出 (1 + 2) * (x - 3) std::cout << '\n'; std::unordered_map<std::string, double> env{{"x", 10.0}}; Evaluator evaluator(env); expr->accept(evaluator); std::cout << evaluator.result << '\n'; // 输出 21 NodeCounter counter; expr->accept(counter); std::cout << counter.count << '\n'; // 输出 5 }

运行结果是:

((1 + 2) * (x - 3)) 21 5

这里有个细节:打印输出的括号比原表达式多一层,因为打印访问者是对每个 BinaryOp 都加括号,没有做优先级优化。如果你需要生成紧凑表达式,可以在打印器里根据运算符优先级选择性加括号,但那属于打印逻辑本身的问题,和访问者模式的结构无关。

3.4 新增算法与新增节点的成本对比

现在到了模式收益最直观的地方。假设我要新加一个“检查表达式里有没有除法”的算法,我只需要写一个新类:

struct ContainsDivision : AstVisitor { bool found = false; void visit(const Number& node) override { (void)node; } void visit(const Variable& node) override { (void)node; } void visit(const BinaryOp& node) override { if (node.op == '/') found = true; node.lhs->accept(*this); node.rhs->accept(*this); } };

节点文件完全不用碰。即使这个算法被十几个节点类型共享,也只是在每一个节点上都写一行空实现。这种“新算法零侵入”的体验,正是访问者模式的设计目标。

反过来,如果我要加一个新的节点类型,比如UnaryMinus(一元负号),代价就大了:

struct UnaryMinus : AstNode { std::unique_ptr<AstNode> operand; explicit UnaryMinus(std::unique_ptr<AstNode> o) : operand(std::move(o)) {} void accept(AstVisitor& v) const override { v.visit(*this); } };

与此同时,AstVisitor 接口要加一个virtual void visit(const UnaryMinus& node) = 0;,于是所有已有的 Visitor 类都必须补上这个方法的实现,否则编译失败。这看起来是缺点,实际上是一种保护:编译器强制你把所有可能的节点类型都处理一遍。相比运行时拿到一个未知类型静默处理,这种“哪里没改就报错”的机制反而让你在重构时安心。

如果你不想每个 Visitor 都改,也可以给接口加默认实现:

virtual void visit(const UnaryMinus& node) { node.operand->accept(*this); }

这样新类型加进来后,老 Visitor 不用改也能跑,但代价是“忘记针对 UnaryMinus 做专门处理”这件事不会再被编译器发现。默认实现的好坏,取决于你的团队更怕编译改动多,还是更怕运行时漏行为。我个人的经验是:在小团队、再小的工程里,纯虚函数强制实现更安全;在大团队、超大类型体系里,给一个默认兜底实现更现实。

4. 常见坑与实战排查

4.1 头文件循环依赖:谁应该 include 谁

访问者模式很容易写出循环依赖。节点头文件需要 visitor 的完整定义,因为要在类内实现 accept 调用v.visit(*this);visitor 头文件又需要所有节点类型的声明,因为要写visit(const Number&)这样的重载。如果两边都直接 include,编译器直接死循环。

正确姿势是:visitor 头文件只前置声明节点类型,不 include 节点定义;节点头文件 include visitor 头文件。前置声明足以让visit(const Number&)这种引用参数通过编译,因为声明一个函数参数类型为 Number 的引用不需要 Number 的完整定义。真正的完整定义放在节点头文件里,visitor 的实现文件(.cpp)在需要访问节点成员时才去 include 节点头文件。

如果实在要双向可见,也可以用接口类和实现类分离的方式,但那样会导致代码量翻倍,我试过几次,收益也不大。对于中小型项目,前置声明方案是最轻的解法。

4.2 const 重载陷阱:一个 “visit” 匹配错了方向

访问者模式在 C++ 里有个隐藏 bug 非常难查:重载解析选择了基类版本。我举个例子,如果你在 Visitor 里同时写了:

virtual void visit(const AstNode& node); // 基类兜底 virtual void visit(const BinaryOp& node); // 具体类型

那么当节点 accept 里执行v.visit(*this)时,*this是const BinaryOp&,编译器在重载集合里选择“最匹配”的那个。按理说const BinaryOp&比const AstNode&更精确,会选具体版本。但如果你的具体版本写成了visit(BinaryOp& node),而节点 accept 是 const 的、传出来的是const BinaryOp&,那么非 const 引用版本就不可调用,编译器悄悄退回基类版本。这个错误发生得非常安静,逻辑上表现为“所有类型的节点都走了同一个分支”。

我的排查建议是:第一,节点 accept 统一保持 const,visit 参数也统一写成 const 引用,两头一致;第二,不要在 Candidate 集合里放基类兜底版本,如果必须放,就给它一个非常显眼的名字,比如visitFallback,避免重载参与自觉选择;第三,如果出现诡异行为,优先检查有没有“类型不精确匹配导致退化选择”的情况。

4.3 有状态访问者的经典陷阱:递归时结果被覆盖

这一节值得单独拿出来说,因为我在代码评审里见过太多次。带状态的 Visitor 通常在成员变量里保存结果,比如求值器的 result、计数器里的 count。当你递归调用子节点的 accept 时,内层调用会覆盖外层还没用掉的状态,导致结果丢失。

最典型的案例就是前面 Evaluator 的递归求值。第一次写很容易写成:

void visit(const BinaryOp& node) override { node.lhs->accept(*this); node.rhs->accept(*this); // 此时 lhs 的值已经没了! result = lhs + rhs; }

因为两次 accept 用的是同一个 *this,第二次把 result 覆盖了。正确做法是在第一次结果算完后立刻保存:

node.lhs->accept(*this); const double left = result; node.rhs->accept(*this); const double right = result;

这和普通递归函数“在函数调用后保存局部变量”是同一个道理,但因为状态藏在了 this 里,特别容易被忽略。我的习惯是:凡是在 Visitor 里持有可变成员状态,递归之前先明确写出哪些状态需要快照,哪些状态允许被覆盖。不写明白,后面维护代码的人一定会踩坑。

4.4 深层表达式容易爆栈

访问者模式天然用递归遍历组合结构。表达式嵌套非常深的时候,比如深度达到几万层的 AST,函数调用栈很容易爆掉。你可以用ulimit -s临时放大栈,但这不是根治办法。

真正的解法是改成显式栈的迭代遍历。我自己在写一个带嵌套函数的配置解释器时,碰到过 5 万层嵌套直接把进程打崩的情况,后来把求值器从递归改成显式栈后,内存平稳了,速度反而快了一些。大致思路是:用一个std::vector<const AstNode*> stack模拟后序遍历,节点入栈时标记状态,子节点入栈前先保存中间结果。这段代码比递归版长不少,但在访问者模式里,这个改动是局部的——只影响那一个 Visitor,节点体系和其他 Visitor 完全不用动。这也是访问者模式隔离操作逻辑带来的附带好处。

4.5 性能开销:两次虚函数调用到底多严重

访问者模式在 C++ 里的额外开销是每个节点两次虚函数调用。一次是accept的虚分派,一次是visit的虚分派。如果节点体量很大,这个开销在总计算里非常小;如果节点是个轻量的数学表达式、每个节点只有几十个时钟周期的工作量,那两次虚函数的开销占比就很明显。

工程判断标准是这样的:如果你的节点数在百万级以下,且一次完整遍历的时间预算在毫秒级,访问者模式的开销通常不会成为瓶颈。如果性能敏感,可以先用 profile 确认热点。真到那一步,std::variant + std::visit通常是更好的选择,它只做一次 switch 分发,还能被内联,开销比虚函数低一个量级。

4.6 常见问题速查表

症状可能原因处理方式
编译报“未实现纯虚函数”新增节点类型后,个别 Visitor 没补齐 visit按报错逐个补实现;如允许漏处理,给接口加默认实现
所有节点都执行了同一个 visit 分支accept 的 const 与 visit 参数 const 不匹配,重载退化选择基类版本统一 const 引用;避免把兜底版本放同名字重载
递归求值结果被后访问的子树覆盖没有在递归前保存中间状态每次 accept 子节点后立即把 result 快照到局部变量
嵌套过深直接栈溢出递归遍历导致调用栈耗尽改显式栈迭代遍历;或调大栈空间临时验证
头文件互相 include 卡死节点头文件和 visitor 头文件循环依赖用前置声明打破循环;实现文件里再 include 完整定义

5. 现代 C++ 的替代方案:std::variant 与 visit

5.1 variant 版表达式求值快速上手

如果你的类型集合是封闭的,那 C++17 之后的std::variant + std::visit几乎是为这个场景量身定做的替代方案。它把你“类型集合”和“操作集合”的扩展关系暴露得非常清楚。

比如同样做一个表达式系统,用 variant 定义封闭类型集:

#include <variant> struct Num { double value; }; struct BinOp { char op; std::unique_ptr<ExprNode> lhs; std::unique_ptr<ExprNode> rhs; }; // 递归 variant 的常规写法:用一个中间包装 struct ExprNode; struct ExprHolder { std::unique_ptr<ExprNode> node; }; struct ExprNode { std::variant<Num, BinOp, ExprHolder> data; }; using Expr = std::unique_ptr<ExprNode>;

然后在访问侧写一个std::visit,利用 C++17 的if constexpr做类型分发:

double eval(const ExprNode& e) { return std::visit( [&](const auto& x) -> double { using T = std::decay_t<decltype(x)>; if constexpr (std::is_same_v<T, Num>) { return x.value; } else if constexpr (std::is_same_v<T, BinOp>) { double l = eval(*x.lhs); double r = eval(*x.rhs); switch (x.op) { case '+': return l + r; /* ... */ } } else { return eval(*x.node); } }, e.data); }

这里std::visit会在运行时根据variant当前持有哪些类型,自动调用 lambda 的对应分支。它不需要节点类里有accept虚函数,也不需要专门的 Visitor 类,类型分派全部由标准库完成。

5.2 访问者模式和 variant 的取舍

两者解决的问题很像,但生态位不同。我用一张表总结一下我在实际选型时是怎么权衡的:

维度传统访问者模式(继承体系)std::variant + std::visit
类型集合开放,可随时通过继承扩展封闭,编译期固定
新增类型需要所有 Visitor 补实现需要所有 visit 调用点补分支
新增操作新增一个 Visitor 类即可新增一个 visit lambda 即可
存储方式指针/引用,适合放在对象树里值语义,适合状态紧凑的小对象
分派开销两次虚函数调用通常一次 switch,可内联,性能更优
与普通多态混合天然兼容基类指针容器不方便,variant 不能存不同容器类型

关键判断是:如果类型体系是封闭的,或者你要处理的是小对象集合,直接用 variant 更简单、更快、类型更安全。如果你需要开放类型体系,或者对象要作为多态指针存在同一容器里,比如vector<unique_ptr<AstNode>>,那么传统访问者模式更合适。很多网上教程说“用 variant 取代访问者模式”,这个说法只对了一半,正确说法是“类型封闭时用 variant 取代访问者模式”。

5.3 两者混用的实战思路

我项目里最常见的是两套方案混用。解析阶段生成的类型集合相对封闭,我会用 variant 做快速求值和单元测试;一旦要进入多阶段分析,比如编译器中段要叠加各种优化处理,节点类型体系会不断扩展,我会换成继承 + 访问者模式。切换的边界是“类型集合是否会随着项目演进继续扩大”。

还有一种混合姿势:节点用继承体系,但内部数据用 variant 做状态字段。比如 BinaryOp 节点的操作符类型是std::variant<AddOp, SubOp, MulOp>,外层访问者处理结构,内层 visit 处理操作符。这样结构层是开放的,具体操作符是封闭的,两个维度各自用最省事的方案。这种“模式组合”在成熟项目里很常见,你不需要二选一。

最后聊点实操体会。我从最开始写访问者模式只会照抄 Shape 例子的accept,到后来在真正的解释器项目里大规模使用它,最大的感受是这个模式的价值不在于代码里出现了 Visitor、accept 这些词,而在于你找准了“类型稳定、操作易变”这个边界。那套求值器代码,后来加了类型检查、打印生成、符号表统计等五六个 Visitor,节点类几乎没有因为新增操作而改动过,这种体验真的很爽。但如果让我给新项目做技术选型,我会先问一句:这个类型集合真的是开放的吗?如果不是,std::variant 多半更香。判断得准,比姿势帅重要得多。

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

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

立即咨询