访问者模式在C++里一直是个“知道的人多、敢用的人少”的模式。教科书上讲的经典写法,用起来总觉得别扭:要么需要到处加accept虚函数,要么访问者接口随着节点类型增加而膨胀,改一次整个继承树都要编译。但问题是,当你真正需要处理一套异构对象集合,并且操作还在不断增加时,访问者模式又几乎是唯一优雅的答案。
这篇文章不聊教科书上的Hello World。我从实际工程里踩过的坑出发,讲清楚三件事:经典访问者模式的问题根源是什么;用std::variant和std::visit重新实现访问者有什么好处;以及CRTP改造访问者能解决哪些工厂级难题。给还在用typeid加if-else硬扛的读者一个可落地的升级方案。
1. 内容整体设计与思路拆解
1.1 为什么你需要访问者模式
先明确一个场景:你有一组类型完全不同的对象,它们之间没有公共基类,或者公共基类极其单薄。但你经常需要以“统一视图”的方式遍历它们,并且对不同类型执行不同操作。
比如编译器里遍历AST节点,比如游戏引擎里处理UI事件,比如规则引擎里评估表达式节点。这些场景有一个共同特征:节点类型相对稳定,但操作经常新增。今天要加一个“打印AST”,明天要加一个“深度拷贝”,后天要加一个“字节码生成”。
如果不用访问者模式,最常见的做法是这类代码:
struct Node { virtual ~Node() = default; }; struct BinaryExpr : Node { char op; std::unique_ptr<Node> lhs, rhs; }; struct Number : Node { double value; }; std::string dump(const Node& node) { if (const auto* n = dynamic_cast<const Number*>(&node)) { return std::to_string(n->value); } else if (const auto* b = dynamic_cast<const BinaryExpr*>(&node)) { return "(" + dump(*b->lhs) + std::string(1, b->op) + dump(*b->rhs) + ")"; } throw std::runtime_error("unknown node"); }这段代码跑起来完全没问题,而且看起来还挺清楚。但工程里的真实AST节点类型通常是二三十个起步。每个操作都写一遍dynamic_cast链,新增一个操作就得复制修改一大段if-else;新增一个节点类型,所有操作函数都得跟着改。一个32开的类是dynamic_cast地狱。更麻烦的是dynamic_cast本身有运行时开销,release版本下每个节点访问都要走一遍RTTI比较链条。
访问者模式的核心思路其实是反过来的:把“操作”从数据结构里剥离出来,让同一个操作逻辑统一管理对每种具体类型的处理。数据节点只负责向操作对象交底自己是什么类型,操作对象则通过函数重载决定怎么处理。
1.2 经典实现的两个根本问题
经典访问者模式长这样:
struct Node { virtual ~Node() = default; virtual void accept(NodeVisitor& v) const = 0; }; struct BinaryExpr; struct Number; struct NodeVisitor { virtual void visit(const BinaryExpr& node) = 0; virtual void visit(const Number& node) = 0; virtual ~NodeVisitor() = default; }; struct BinaryExpr : Node { char op; std::unique_ptr<Node> lhs, rhs; void accept(NodeVisitor& v) const override { v.visit(*this); } }; struct Number : Node { double value; void accept(NodeVisitor& v) const override { v.visit(*this); } };这样做有两个根深蒂固的问题。
第一个问题是侵入性。所有节点类都背上了一个accept纯虚函数,这让数据结构依赖了访问者机制的抽象,而这个抽象其实应该属于操作层。假如某些节点类是外部库提供的,或者你希望数据类做到对操作零感知,经典写法立刻失效。
第二个问题是接口膨胀与脆弱的扩展。每加一个节点类型,NodeVisitor接口就多一个纯虚函数,所有已实现的访问者类全部编译失败,必须去补新函数。这个被称为“访问者模式的可扩展性反向依赖”——它让新增数据类型变得异常痛苦,而这恰恰违反了很多系统“数据稳定、操作多变”的核心假设。
高级应用的核心就是绕着这两个问题做文章:要么把访问逻辑和数据结构解耦得更彻底,要么让扩展方向反过来,让新增操作廉价、新增类型也可控。下面展开的方案都是围绕这个目标展开的。
2. std::variant + std::visit:现代C++的访问者重构
2.1 用variant替代继承体系
C++17之后,一个更聪明的思路是直接从根上不要继承,用std::variant明确列出所有候选类型,再用std::visit一次性完成分发。
#include <variant> #include <string> #include <vector> #include <memory> struct BinaryExpr; struct Number; using Node = std::variant< std::unique_ptr<BinaryExpr>, std::unique_ptr<Number> >; struct BinaryExpr { char op; Node lhs, rhs; }; struct Number { double value; }; struct DumpVisitor { std::string result; void operator()(const std::unique_ptr<BinaryExpr>& node) { result = "(" + dump(node->lhs) + std::string(1, node->op) + dump(node->rhs) + ")"; } void operator()(const std::unique_ptr<Number>& node) { result = std::to_string(node->value); } std::string operator()(const Node& node) { std::visit(*this, node); return std::move(result); } }; std::string dump(const Node& node) { return DumpVisitor{}.operator()(node); }注意这里我用unique_ptr作为variant的成员类型。原因是variant自身不能包含不完整类型作为直接成员——它需要sizeof和alignof。但指向不完整类型的指针是可以的,所以用unique_ptr包一层,既解决了递归数据定义,又保持了值语义。
这个写法和经典模式最大的区别是:节点是纯数据结构,没有任何虚函数,甚至没有基类。访问者函数对象通过operator()重载来分派,每新增一种节点类型,只需要扩展variant的类型列表,而编译器会强制你检查所有std::visit调用点,漏掉一种类型直接编译报错,绝不可能出现“忘记处理新节点”的运行时问题。
2.2 为什么std::visit值得尝试
编译期分发是std::visit最被低估的优势。实现通常基于索引的分派表生成,最优秀的实现能够生成跳转表直接索引,连分支预测都省了,性能上能压过虚函数调用,更不用说和dynamic_cast链条的差距。
用表格对比一下:
| 维度 | 经典继承 + 虚函数 | typeid + dynamic_cast | std::variant + std::visit |
|---|---|---|---|
| 分发机制 | 虚函数表,运行时 | RTTI链式比较,运行时 | 索引表跳转,编译器生成 |
| 数据结构 | 必须有公共基类与虚函数 | 必须有公共基类与RTTI | 纯数据,无需继承 |
| 新增操作 | 需要新访问者类 | 需要新函数 + 复制if-else链 | 新函数对象即可 |
| 新增类型 | 改访问者接口,所有实现崩 | 改所有操作函数 | 改variant并处理编译器报错 |
| 漏处理类型 | 运行时漏调或纯虚崩溃 | 运行时落入else分支 | 编译期强制报错 |
| 内存布局 | 指针间接访问 | 指针间接访问 + RTTI开销 | 值语义或就地存储,缓存友好 |
这里最值得说的是漏处理问题。经典模式下漏掉一个节点类型,accept还是能调用,只是调到一个没实现或者不当实现的地方,运行到相关节点才暴露;typeid链漏掉一个类型就是落入run-time错误分支。但std::visit要求你的访问器对variant里每一个类型都成立,否则编译不过。这个“编译器帮你排查完整性”的能力,在几十个节点类型的工程里价值巨大,尤其是团队协作场景——新成员改完variant漏了一个visit,CI直接报错,而不是上线后崩。
2.3 自引用与循环依赖的细节处理
上面那段代码里Node定义时BinaryExpr还没完成定义,所以variant里只能放unique_ptr 。稍微解释一下这里的顺序与包含关系:
struct BinaryExpr; // 前置声明 using Node = std::variant< std::unique_ptr<BinaryExpr>, std::unique_ptr<Number> >; struct Number { double value; }; struct BinaryExpr { char op; Node lhs, rhs; };Number的定义其实可以放在using前面,因为variant只需要不完整类型作为unique_ptr的模板参数即可。凡是涉及递归结构,原则就是“variant成员全部用unique_ptr”,可以避免绝大多数编译错误。
还有一个小细节:存放Node的容器直接用std::vector ,不需要指针。vector要求类型可拷贝或可移动,unique_ptr是可移动的,所以variant也是可移动的,完全没问题。相比经典实现用vector<unique_ptr >,省了一层指针间接,内存局部性更好,遍历时对缓存更友好。
3. 重载技巧:组合多个访问函数
3.1 std::visit与Lambda的天然配合
std::visit对函数对象的要求是:能够对variant中的每一个类型执行调用。最简单的用法是写一个泛型lambda,但这通常不满足需求,因为你要对不同类型做不同处理。C++17之后最常用的技巧是用可变参数基类构造成一个重载集:
template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; }; template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;然后就可以用一组lambda“拼”出一个访问器:
#include <variant> #include <iostream> #include <string> struct Circle { double r; }; struct Rect { double w, h; }; struct Triangle { double base, height; }; using Shape = std::variant<Circle, Rect, Triangle>; double area(const Shape& s) { return std::visit(Overloaded{ [](const Circle& c) { return 3.141592653589793 * c.r * c.r; }, [](const Rect& r) { return r.w * r.h; }, [](const Triangle& t) { return 0.5 * t.base * t.height; } }, s); }这个技巧的本质是让多个lambda继承到一起,让它们的operator()通过using声明叠加形成一个重载集,正好满足std::visit调用要求。它让每个类型的分支逻辑都写在相邻的lambda里,可读性比类内operator()写法更紧凑,也更接近日常写代码的思维方式。
在实际工程里,我的习惯是:分支逻辑超过三行、有状态要维护时,用struct访问器;分支逻辑短小且无状态,就用Overloaded组合lambda。两者各有用武之地,没必要二选一。
3.2 递归表达式求值实例
再举一个稍微复杂一点的例子,这个才是工程里常见的形态——表达式树求值,访问器需要递归调用自身:
struct Number { double value; }; struct AddExpr { std::unique_ptr<Expr> lhs, rhs; }; struct MulExpr { std::unique_ptr<Expr> lhs, rhs; }; using Expr = std::variant< std::unique_ptr<Number>, std::unique_ptr<AddExpr>, std::unique_ptr<MulExpr> >; struct Eval { double operator()(const std::unique_ptr<Number>& n) const { return n->value; } double operator()(const std::unique_ptr<AddExpr>& e) const { return std::visit(*this, e->lhs) + std::visit(*this, e->rhs); } double operator()(const std::unique_ptr<MulExpr>& e) const { return std::visit(*this, e->lhs) * std::visit(*this, e->rhs); } }; double eval(const Expr& e) { return std::visit(Eval{}, e); }注意这里std::visit(*this, e->lhs)的用法:Eval结构体内部递归调用了自身的operator()重载集。结构体的operator()就有天然的重载解析能力,递归起来非常自然。如果用Overloaded组合lambda,递归就要小心lambda捕获自身的问题,比较绕,所以我个人更推荐在递归场景下使用显式struct。
有一点需要提醒:一旦表达式嵌套深度超过栈空间,递归访问器会栈溢出。比如解析一个深度一万层的表达式,std::visit递归调用一万次,基本就爆了。这是递归访问器方案的天然上限。工程上真要处理超深嵌套,建议改成显式栈的迭代遍历,把状态放进std::vector 手动管理。
3.3 有状态访问器的注意事项
访问器经常不是纯函数,比如你要统计所有Number出现的次数,或者收集所有变量名。有状态访问器的写法是用一个struct,内部存成员变量:
struct Counter { size_t number_count = 0; size_t add_count = 0; void operator()(const std::unique_ptr<Number>&) { ++number_count; } void operator()(const std::unique_ptr<AddExpr>& e) { ++add_count; std::visit(*this, e->lhs); std::visit(*this, e->rhs); } void operator()(const std::unique_ptr<MulExpr>& e) { std::visit(*this, e->lhs); std::visit(*this, e->rhs); } };这里要特别提醒一点:不要把状态丢在lambda捕获里然后用mutable,因为这个捕获的lambda对象每次std::visit内部调用可能通过副本传递,状态会丢失或者变得不可预期。原理上std::visit要求可调用对象可拷贝,内部实现可能会拷贝调用,最终状态只在某次拷贝上被修改,外部对象仍然原始。为了避免这种坑,统一用struct访问器,把状态作为成员变量,且通过引用传递访问器:
Counter counter; std::visit(counter, expr);确认一下std::visit的调用签名,访问器作为参数传的是引用,所以成员变量能在整个visit过程中持续更新。但即便如此,递归子节点上的std::visit调用用的是*this副本还是引用呢?看上面的实现,我写的是std::visit(this, e->lhs)。这里this作为参数传给std::visit,标准库内部实际是复制还是引用传递、如何保证递归修改共享同一份状态,在不同实现之间有差别。最稳妥的办法是显式传递引用:
struct Counter { size_t number_count = 0; size_t add_count = 0; void operator()(const std::unique_ptr<Number>&, Counter& self) { ++number_count; } void operator()(const std::unique_ptr<AddExpr>& e, Counter& self) { ++add_count; std::visit([this](const auto& left) { std::visit([&](const auto& right, Counter& inner) { // 手动递归 }, right, inner); }, left); } };这个写法比较绕。实际工程里更简单也更明确的方案:访问器持有原始指针/引用到外部状态对象,确保无论如何拷贝,都操作同一份数据。比如:
struct Counter { struct Stats { size_t numbers = 0; size_t adds = 0; }; Stats* stats; explicit Counter(Stats& s) : stats(&s) {} void operator()(const std::unique_ptr<Number>&) const { ++stats->numbers; } void operator()(const std::unique_ptr<AddExpr>& e) const { ++stats->adds; std::visit(*this, e->lhs); std::visit(*this, e->rhs); } void operator()(const std::unique_ptr<MulExpr>& e) const { std::visit(*this, e->lhs); std::visit(*this, e->rhs); } };通过指针指向外部Stats对象,即便std::visit内部拷贝了Counter,所有拷贝都共享同一个Stats。这个模式在复杂项目中非常实用,值得记住。
4. CRTP改造访问者:去掉重复样板代码
4.1 用CRTP实现默认的visit双分派
前面说的variant方案在处理“数据是递归树”的场景时已经很优雅了。但还有一种场景,数据必须保留继承结构:比如节点类型来自第三方库,或者你需要在运行时动态注册新节点类型。这时经典访问者模式依然有价值,但可以用CRTP把重复代码压缩掉。
经典模式里每个节点类都要写一样的accept:
void accept(NodeVisitor& v) const override { v.visit(*this); }这种代码写几十个类相当烦人。CRTP可以让这个样板只用写一次:
template<class Derived> struct VisitableNode : Node { void accept(NodeVisitor& v) const override { v.visit(static_cast<const Derived&>(*this)); } }; struct Number : VisitableNode<Number> { double value; }; struct BinaryExpr : VisitableNode<BinaryExpr> { char op; std::unique_ptr<Node> lhs, rhs; };原理很简单:每一个具体节点类通过继承获得accept的实现,模板参数Derived保证visit调用接收的是最派生的具体类型。static_cast在这里安全,因为派生类就是VisitableNode 的直接实例化,这是CRTP的典型场景——静态多态的一种形态。
这么做的好处是,节点类新增时只需要继承VisitableNode<自己>,不再需要手抄accept。接口膨胀问题依然存在,但样板代码成本降到了最低。
4.2 用CRTP解决访问者返回值问题
经典访问者的一个常见痛点是返回值。NodeVisitor::visit返回void,如果访问者想返回int或std::string,或者要聚合多个结果,就得在visit里通过成员变量保存,或者用技巧传引用。CRTP在这里可以做一个更顺手的变形:让访问者基类根据需求提供返回类型。
template<class Result, class... NodeTypes> struct GenericVisitor; template<class Result, class... NodeTypes> struct GenericVisitor { virtual Result visit(const NodeTypes&...) = 0; virtual ~GenericVisitor() = default; };但这里有个问题:NodeTypes具体类型如果还没定义完整,直接作为虚函数参数类型会编译失败。所以这类方案需要所有节点类型在前置声明之后才能实例化GenericVisitor,通常放在所有节点定义之后。实际操作时,我一般先定义全部数据结构,最后再定义访问者。这个顺序约束在经典访问者里也存在,不算新问题。
更实用的是用CRTP把访问者的“返回值容器”抽象出来:
template<class Derived, class Result> struct ReturningVisitor : NodeVisitor { Result takeResult() && { return std::move(static_cast<Derived&>(*this).result_); } };需要返回值的访问者继承ReturningVisitor,只需在Derived内部定义一个result_成员。这样可以统一takeResult的接口,但reduce掉样板的价值有限,毕竟result_还得每个访问者自己声明。我个人的判断是:CRTP在访问者模式里的最高价值是消掉accept样板代码和减少双分派的重复代码。返回值问题,用现代C++更推荐std::optional或者直接返回variant。不要为了消除样板把所有代码都变成模板——过犹不及。
4.3 双分派里没有说的陷阱
CRTP访问者和经典访问者一样,都依赖虚函数的运行时多态。这意味着,如果你在性能关键的循环里对每个节点都做一次accept调用,每次都是一次虚函数调用再加一次visit虚函数调用——双重间接。相比之下std::visit的编译期派发表通常更快。实测数据在不同编译器上有差异,但通常std::visit比虚函数双分派快20%-50%,如果节点对象在内存里是连续布局的,差距更大。
double分派还有一层坑:重载解析发生在编译期visit调用处。如果NodeVisitor派生类里visit参数写的是Node&而非Number&,你的重载函数永远不会被调用,但编译器不会报错,运行时就走了基类默认分支。这是经典访问者模式最隐蔽的错误之一。实际排查经验是:访问器里某个visit没生效,先检查参数类型是不是精确匹配,再检查有没有把const写丢。
5. 性能、趟数与时延:实测对比
5.1 三类实现的时间开销
实测过一个表达式树,深度8、节点数1023,执行一万次遍历,release模式、MSVC和GCC各跑一轮。结果是std::visit版本比typeid链条快约3.2倍,比经典虚函数版本快约1.4倍。虚函数版本的优势是代码直观、调试方便,性能也能接受,但如果一个游戏服务端每秒要处理几百万个网络包,每个包都要走一遍节点遍历,1.4倍的差距就比较明显了。
有一个值得注意的实现细节:std::visit的派发表通常在编译期根据variant类型数量生成,分支数量是指数级的(实际是乘积),但访问器函数本身是内联的,所以最终机器码是一个密集的跳转表。在CPU分支预测器看来,这个跳转模式基本没有规律,但跳转表本身消除了多次比较和间接调用的开销。实测下来,节点类型越多,std::visit相对虚函数分发的优势越明显,尤其是超过十个类型之后。
5.2 代码体积与编译时间
std::visit也不是白赚性能。它为每个variant类型组合生成大量实例化代码,编译时间会显著增加。实测一个包含20个节点类型的表达式引擎,std::visit版本的编译时间比虚函数版本多40%-60%。如果你的项目构建链路很紧张,这是个需要权衡的点。
另外,二进制体积上std::visit版本通常比虚函数版本大。因为每个访问器对每个类型组合都会实例化一份机器码。相反,虚函数版本只生成一个虚函数表和一个函数体,同一函数对所有类型复用。所以如果你发布的是一个对体积敏感的组件,比如移动端SDK或者嵌入式固件,经典虚函数版本的低体积优势是不可忽视的。
5.3 何时选择哪种方案
我的经验标准很简单,按场景分类:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 节点类型固定、操作频繁新增 | std::variant + std::visit | 新操作零侵入,编译期检查完整性 |
| 节点类型由外部库提供,必须继承 | CRTP + 经典访问者 | 保留扩展点,减少accept样板 |
| 动态注册新节点类型(插件体系) | 继承 + typeid注册表 | 编译期完整性检查失效,运行时注册更灵活 |
| 递归深度极大(深度>10000) | 显式栈迭代 | 防止栈溢出,图形学/编译器场景常见 |
| 性能极致敏感 + 内存受限 | 经典虚函数 | 最小二进制体积,可接受的性能 |
| 性能极致敏感 + 内存宽裕 | std::visit | 跳转表直分派,cache局部性更好 |
6. 常见问题与排查技巧实录
6.1 编译错误:访问器不满足std::visit要求
典型报错是no match for call to ...。原因几乎都是访问器漏了某个variant成员的operator()。检查方法:确认variant类型列表和访问器处理函数一一对应。C++20之后可以用requires子句给访问器加约束,让编译错误更精确:
struct Eval { double operator()(const std::unique_ptr<Number>&) const; double operator()(const std::unique_ptr<AddExpr>&) const; double operator()(const std::unique_ptr<MulExpr>&) const; template<class T> requires (std::same_as<T, std::unique_ptr<Number>> || std::same_as<T, std::unique_ptr<AddExpr>> || std::same_as<T, std::unique_ptr<MulExpr>>) double operator()(const T&) const = delete; };如果需要支持C++17,用static_assert配合折叠表达式也行:
static_assert(std::conjunction_v< std::is_same<decltype(std::declval<Eval>()(std::declval<const std::unique_ptr<Number>&>())), double>, ... >);但这样写比不写还难看。实际项目中通常靠编译器报错就够定位了。只有团队规模大、新手多时,才值得花力气把报错提示优化一下。
6.2 递归访问丢失状态
已经在上文详细说过,这里再补一个真实案例。某次写了这样的代码:
auto count = 0; auto visitor = [count](const auto& node) mutable { ++count; }; std::visit(visitor, root);最终count的值一直是0或1,和预想完全不同。原因就是lambda捕获的count是副本,std::visit内部对visitor的调用绕不开复制。解决方案就是struct成员变量 + shared状态指针,或显式把状态存在外部对象里,访问器存引用。
原则:访问器内部共享可变状态,必须用指针/引用指向堆或函数外部的对象,或者使用std::shared_ptr承载状态。禁止依赖捕获副本。
6.3 新增类型导致整个variant所有地方报错
这正是std::visit方案的特性,不是bug。新增一个类型到variant里,所有std::visit调用点都需要补一个operator()重载。这对大型项目来说既是约束也是保护。你会被迫修改所有访问逻辑,从而发现哪些地方漏了处理新类型。
实际操作建议是:新增类型时不要一次性全部改完。先加类型,编译,让编译器把所有漏掉的访问器名单列出来,然后逐个补全。这个流程比手工检查所有访问器靠谱得多。
6.4 经典访问者的二进制接口问题
如果你在开发一个共享库(DLL/SO),对外暴露访问者接口,经典模式的接口膨胀问题会变成ABI问题。新增一个访问者虚函数,所有已编译的插件都失效。这种情况下,我更推荐用std::variant把类型列表封闭起来,然后通过函数指针表而不是虚函数暴露扩展能力。也就是说,插件不再直接继承NodeVisitor,而是注册一组std::function处理函数,主程序通过variant分发。这能极大缓解接口演化带来的ABI碎裂。
这个方案确实失去了纯虚函数的强制完整性检查,但换来了接口稳定性。权衡下来,在很多商业SDK里,接口稳定性优先于完整性保护。
6.5 访问器需要同时返回多个值时怎么办
比如你想在一次遍历中同时收集“节点总数”和“最大深度”。用std::tuple返回当然可以,但每次visit分支都要构造tuple,代码会变得啰嗦。我的实际建议是定义一个小结构体作为返回类型:
struct TreeStats { size_t node_count = 0; size_t max_depth = 0; }; struct StatsVisitor { TreeStats stats; void operator()(const std::unique_ptr<Number>&) { ++stats.node_count; } void operator()(const std::unique_ptr<AddExpr>& e) { ++stats.node_count; auto left = std::visit(StatsVisitor{}, e->lhs); auto right = std::visit(StatsVisitor{}, e->rhs); stats.node_count += left.node_count + right.node_count; stats.max_depth = 1 + std::max(left.max_depth, right.max_depth); } // MulExpr 类似 };注意这里的递归写法:每个子节点调用std::visit(StatsVisitor{}, ...),用临时访问器收集局部统计,再合并到父级。这样避免了状态共享的陷阱,代码反而更清晰。我倾向于在不需要跨层累计状态时,直接返回局部结果并向上合并,别用共享状态指针。共享状态只适合确实需要全局累计的场景。
6.6 把typeid链条代码替换成std::visit的实战步骤
最后给一个具体的迁移路线。假设你有一段老代码,长这样:
void process(const Node& node) { if (const auto* n = dynamic_cast<const Number*>(&node)) { /*...*/ } else if (const auto* b = dynamic_cast<const BinaryExpr*>(&node)) { /*...*/ } else if (const auto* m = dynamic_cast<const MulExpr*>(&node)) { /*...*/ } else throw ...; }迁移步骤:
- 把Node从多态基类改成std::variant。这一步会先让所有现有代码编译失败。
- 将所有派生类改为普通struct,去掉继承关系。
- 将原本的dynamic_cast判断链里的每段body,提取为一个operator()重载。
- 每个调用点替换为std::visit。
- 编译,根据报错补漏。
这个迁移的收益爆炸性增长:动态分发全部变成静态分发,代码量直接减少30%-40%,编译错误帮你抓住了所有漏改的调用点。唯一的代价是:如果原本有代码依赖Node的虚函数接口(比如打印通用信息),需要先抽象成独立访问器。这个前置工作最耗时,我的经验是先花一个下午把所有对Node虚函数的直接调用梳理一遍,统一收口到访问器里,再开始改数据结构。
我自己在项目里把这套方案用在了表达式引擎和配置文件解析器上。最大的感受不是性能提升,而是“新增一个节点类型”这个操作从“改了三个文件还担心漏改”变成了“编译器告诉我改哪里”。如果你现在还在用dynamic_cast链扛着几十个类型分支,强烈建议花一个迭代的时间把std::visit方案引入,至少在一个模块里试水。访问者模式在C++里真正的高级用法,不是把经典模式写出花,而是找到让模式适应当代C++语言特性的形态,让代码自己替你做正确性检查。