尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C++访问者模式实战:遍历骨架、std::variant与维护策略
访问者模式大概是设计模式里被误解最多的一个。很多人把它的“标准模板”背得很顺——一个 Visitor 基类、一堆 Element 类、每个 Element 都写void accept(Visitor v) { v.visit(*this); }——但真把它放进 C 项目里很快就会遇到教科书没讲的事递归嵌套的树结构怎么遍历才不重复写代码多个节点协作的算法是否适合套访问者加了十几个 visitor 之后代码到底更清爽还是更臃肿这篇文章不打算重复入门概念只会围绕高级应用场景把这几个问题拆开遍历骨架怎么设计、std::variant 这类现代工具怎么替换经典双分派、有状态访问者的状态管理以及访问者在大代码库中的维护策略。目标是给已经写过或者正准备写访问者模式的 C 开发者一份能直接参考的实践经验。1. 教科书版访问者的三个短板与重构思路经典访问者的本质是“双重分派”第一次 dispatch 由节点的 accept 完成第二次由 visitor 的重载完成。这个机制本身没毛病但它有三个在入门教程里几乎不会提的短板。1.1 递归结构里的遍历骨架问题举一个很常见的场景表达式树。你定义了Literal、Add等节点每个节点都有 accept。Visitor 基类是class Visitor { public: virtual ~Visitor() default; virtual void visit(const Literal n) {} virtual void visit(const Add n) { n.lhs()-accept(*this); n.rhs()-accept(*this); } };注意这个默认实现里已经把“如何遍历一个二元节点”写进了基类。看起来挺方便但麻烦随后就来。比如你要实现一个代码生成器遍历顺序必须调整为右子树优先因为某些目标指令确实有求值顺序约束。于是你在自己的visit(const Add)里重写一遍左右子树的访问顺序void visit(const Add n) override { n.rhs()-accept(*this); n.lhs()-accept(*this); }再写一个常量折叠器要求在对当前节点做处理之前先完成两个子节点的折叠我又要重新控制顺序。你会发现不同 visitor 之间真正重复的不是具体操作而是“先访问哪棵子树、访问完之后做什么”这一段遍历骨架。每个 visitor 都在用自己的方式重新实现前序、中序、后序遍历。我见过一个比较干净的改写思路把 traversal 单独抽成一个策略或者让基类只负责公共的“进入/离开”时机把当前节点的具体处理留给子类class TraversalVisitor : public Visitor { public: void visit(const Add n) override { enter(n); n.lhs()-accept(*this); mid(n); n.rhs()-accept(*this); exit(n); } // enter/mid/exit 默认空实现子类按需覆写 };这样一来“遍历骨架”确实统一了但马上会暴露一个新问题子类想跳过右子树怎么办比如短路求值时左子树已经确定了整个表达式的结果右子树根本不需要访问。这时把遍历决策完全固定在基类里反而又限制了算法的自由度。所以访问者模式在递归结构里最核心的平衡点是遍历骨架可以统一但必须允许语义层面随时打断它。这块我在第三章“遍历短路”里再展开。递归结构里另一个容易被忽略的细节是 const 处处传染的问题。节点里一旦存了unique_ptr或shared_ptrlhs()和rhs()的返回类型必须和 accept 的 const 限定保持一致。访问者经常出现写了个visit(const Add)却在函数体里想调用非 const 的接口于是被迫在代码里写 const_cast这类味道一旦出现说明访问者的 const 设计早就欠账了。1.2 跨对象访问时不伦不类的双分派访问者模式天然是“单个对象”的操作模型但很多算法真正需要的是一次面对两个对象。典型例子是判断两棵 AST 是否结构相等。按教科书思路你会写一个EqualityVisitor但每个visit(const Add)里都需要访问对面的另一个节点。这里就尴尬了你调用a.accept(*this)时this里并不知道 b 在哪想让双分派继续发生就必须在 visit 函数里把 b 也放进去。写成代码就变成了class EqualityVisitor : public Visitor { public: bool equal(const Expr a, const Expr b) { b_ b; result_ true; a.accept(*this); return result_; } void visit(const Literal a) override { // 想拿到 b 的动态类型却没法再正常分派一次 } };要真正解决这个问题要么再维护一条acceptPair的虚函数链给每个节点写一个专门接受“外部节点 visitor”的重载要么干脆绕开访问者。以我实际写过的代码来看如果类型集合小且稳定直接typeidstatic_cast反而更省事bool equal(const Expr a, const Expr b) { if (typeid(a) ! typeid(b)) return false; if (typeid(a) typeid(Literal)) { const auto la static_castconst Literal(a); const auto lb static_castconst Literal(b); return la.value() lb.value(); } if (typeid(a) typeid(Add)) { const auto aa static_castconst Add(a); const auto ab static_castconst Add(b); return equal(*aa.lhs(), *ab.lhs()) equal(*aa.rhs(), *ab.rhs()); } // ... }这里不是说 typeid 方案没有维护成本而是它至少不需要为“对比两个对象”这个需求重新设计一条完整的分派链路。访问者模式的经典场景是“一个结构 多种算法”跨对象对比更像是“两个结构 一种算法”硬套 visitor 会让双分派变成四不像。我的实践结论是遇到跨对象访问时先别急着套 visitor先判断类型体系是否稳定。如果稳定用枚举、typeid、甚至直接比较节点状态都更直观。1.3 两个方向的扩展博弈加类型还是加操作经典访问者的“开放”方向是加操作新增一个Evaluator、一个PrettyPrinter不需要动任何节点类。但它对“加类型”非常不友好给体系里加一个Negate节点所有现有 visitor 类的visit重载集都要补一个方法少补一个行为就缺一块。这个性质经常被概括成“开操作、闭类型”但很多人没有体会到实际代价。当表达式体系需要新增一种运算时你面临的是一个编译期的连锁修改全局搜一遍继承自Visitor的类数一数有多少个每个都要补visit(const Negate)。如果团队里有人偷懒用基类提供的“默认空实现”来绕开编译错误之后新节点的操作会被静默跳过排查起来极费劲。所以我在项目里通常约定基类默认行为必须是显式报错不允许静默忽略除非某个 visitor 的业务语义本来就允许忽略陌生节点。比如调试打印器可以忽略新节点但求值器和类型检查器绝不能漏。这个约定在工程上比任何技巧都重要因为它把“访问者漏实现”从静默 bug 变成了编译错误或者运行期显式失败。这个权衡在下一章的 std::variant 路线上会呈现出完全不同的面貌。2. std::visit 路线双分派、组合访问者与性能表现C17 把std::variant和std::visit带进标准库之后访问者模式的实现有了第二个主流路线。它没有继承、没有虚函数、没有 accept但背后仍然是同一句指导思想把操作和数据结构分开。2.1 为什么说 variant 是封闭类型集合里的最优载体std::variantLiteral, Negate, Add本质上是一个带类型标签的联合体。它天然适合表达“数量在编译期就固定下来”的节点体系。与经典继承相比它最明显的好处是结构上强制封闭想往 variant 里塞一个新类型必须一次性修改类型列表所有使用了 variant 的代码都会跟着编译报错。这看起来很像是“加类型很痛苦”的缺点但反过来想如果产品需求本来就明确了类型集合长期不变那封闭性就是优点你可以获得穷尽性检查不用担心基类里埋着一个“忽略新类型”的默认实现。我见过不少团队的第一反应是“有继承体系为什么要换成 variant”其实两者面对的形态不一样经典访问者处理“开放类型 开放操作”中的一侧variant visit 则适合“封闭类型 任意操作”。用递归表达式树举例struct Literal { double value; }; struct Negate { std::shared_ptrExpr expr; }; struct Add { std::shared_ptrExpr lhs, rhs; }; using Expr std::variantLiteral, Negate, Add;这里用shared_ptr而不是unique_ptr是因为写std::visit时经常要拷贝当前访问器或者传递节点引用虽然unique_ptr也能通过引用传入但shared_ptr在递归访问里写起来更顺手也避免了一些生命周期拷贝细节问题。另一个更根本的原因是variant本身不能包含不完整类型递归结构必须通过某种指针来打破循环。这个细节在入门帖里很少提到,第一次写的人基本都会踩一下编译错误。2.2 overloaded 组合访问者的正确打开方式访问者本体就是一个函数对象集合。最舒服的写法是利用推导指南构造overloadedtemplatetypename... Ts struct overloaded : Ts... { using Ts::operator()...; }; templatetypename... Ts overloaded(Ts...) - overloadedTs...;然后struct Evaluator { double operator()(const Literal l) const { return l.value; } double operator()(const Negate n) const { return -std::visit(Evaluator{}, *n.expr); } double operator()(const Add a) const { return std::visit(Evaluator{}, *a.lhs) std::visit(Evaluator{}, *a.rhs); } };每个operator()都对应经典 visitor 里的一个visit重载分派由std::visit自动完成它拿到 variant 当前的类型然后把对应的operator()调用出来。这个写法的好处第一是安全——某个类型漏了处理编译期根本过不去第二是遍历逻辑可以按需要写不会像经典 visitor 那样陷入骨架重复的泥潭。另一个我常用的场景是把临时处理函数直接拼起来不单独定义类。比如写一个打印表达式的调试函数auto print overloaded{ [](const Literal l, std::ostream os) { os l.value; }, [](const Negate n, std::ostream os) { os -; }, [](const Add a, std::ostream os) { os (; }, };不过这里有个实战陷阱lambda 想要递归调用自身时overloaded变量在其自身初始化器中捕获自己会有生命周期上的模糊性。我的经验是 C17 下尽量用std::function做显式包装或者把递归调用拆到另一个函数里std::functiondouble(const Expr) eval; eval overloaded{ [](const Literal l) { return l.value; }, [](const Negate n) { return -eval(*n.expr); }, [](const Add a) { return eval(*a.lhs) eval(*a.rhs); } };很多人第一次在这里栽跟头以为是自己代码写得不对其实是变量的初始化域和捕获机制的问题。理解了这个坑之后组合访问者用起来就顺畅很多。2.3 开放类型体系不能替代但可以借鉴理念经典访问者和 variant 不是谁淘汰谁的关系。如果你的节点类型允许第三方插件往体系里加那必须走继承那套双分派因为 variant 的类型表是编译期锁死的。反过来如果一个解释器或编译器只是自己内部用类型集合封闭那 variant 是明显更省心的选择。我在一个模拟编译器工具里把一套原本用经典访问者写的 AST 处理切到 variant 之后最大的感受不是性能改变而是“新增操作”这件事的体验完全变了。以前要新建一个 visitor 类现在可以直接在一个函数里用 overloaded 组合临时行为类型处理是否穷尽编译器直接给结论。这套组合方式即使继续使用经典继承结构也值得借鉴——把“一个操作”实现成独立的函数对象而不是塞进一个大的 visitor 类。3. 有状态访问者、const 分派与遍历短路如果说前两章是在讨论“访问者怎么搭”这一章就进入“访问者写起来之后”的日常保养问题了。三个题目看起来小但在实际项目里出现的频率非常高状态、const、短路。3.1 有状态访问者状态生命周期与线程隔离访问者经常要携带语义信息。类型检查器需要一张符号表、一组诊断信息优化器需要一组计数器。这些信息如果直接放在 visitor 成员变量里最容易踩的坑是“访问者被复用”。我见过一段类似这样的代码class TypeChecker : public Visitor { std::unordered_mapconst Expr*, Type types_; public: void check(const Expr e) { types_.clear(); // 每次开始前清理 e.accept(*this); } };看起来每次调用前清空状态没问题但如果 accept 内部在某些分支上抛了异常types_会留下半截状态下次调用时统计就是脏的。更隐蔽的问题是同一时间用同一个 visitor 实例处理多线程任务时types_会成为数据竞争源。最基本的建议有三条把状态对象显式传入构造函数而不是让 visitor 内部自己维护一堆全局数据需要复用时用 RAII 对象做上下文进出栈不要依赖函数末尾手动清理多线程环境里不要共享一个可变 visitor要么每线程建一个要么把状态抽到外部容器统一管理。有状态访问者和遍历骨架结合时一个常见动作是进入节点前压栈离开节点后弹栈。我习惯把这两步放进统一的enter/exit钩子里并确保它们成对出现void visit(const Add n) override { context_.push(/* 当前节点信息 */); n.lhs()-accept(*this); n.rhs()-accept(*this); context_.pop(); }如果 push/pop 可能被异常打断请务必用 scope guard 保证 pop 一定会执行。访问者这种层层虚函数调用的结构里任何“入口写了、出口忘了”的问题都会被放大成很难复现的状态泄漏。这个教训我在一个代码生成器里反复遇到过最后把所有 push/pop 都包进了一个ContextGuard类才算根治。3.2 const 与非 const 的分派容易漏处理的一个角落经典 visitor 的接口通常写成visit(const NodeType)。理由是绝大多数操作只是“读”。但遇到 AST 变换、就地常量折叠这类需要修改节点的操作就得有一个“可变访问者”。问题在于C 的 const 语义在双分派下会变得微妙。先看节点这一侧class Literal { public: void accept(Visitor v) const { v.visit(*this); } void accept(MutableVisitor v) { v.visit(*this); } };Literal对象会优先选择非 const 版本const Literal只能选择 const 版本。这个行为符合预期但有个常见陷阱如果你偷懒只写了void accept(Visitor v) const那么编译时拿到的对象是非 const 的它也能顺利通过这个 const 版本。结果是你想改节点的代码都悄悄走了“只读”通道。我建议把两种 accept 分开写并且在非 const 版本里对传入的MutableVisitor做static_assert防止以后有人把可变操作误接到只读 visitor 上static_assert(std::is_base_of_vMutableVisitor, MutableVisitor);同时访问者自身可能同时需要 const 和非 const 的两套 visit接口数量很容易翻倍。一个比较实用的办法是把“访问”定义成模板化入口让具体实现通过 if constexpr 判断处理 const 还是非 const 对象。不过这个方法在正式代码库里容易把可读性做差。我更推荐在两套 visitor 之间做显式区分只读的继承ConstVisitor要写 AST 的继承MutatingVisitor两者在语义上彻底分开宁可多敲几个字符也不要在一个类里混用两个意图。3.3 短路的遍历控制停止标志与返回值设计经典的 accept 返回 void意味着你没法靠返回值告诉访问者“这棵子树不用继续走了”。递归结构里经常需要短路比如常量折叠时左子树已经是 0再访问右子树就浪费时间或者类型检查遇到一个错误节点后面不用再看。最简单的办法是给 visitor 加一个bool stop_成员每个 visit 开头检查bool visit(const Add n) override { if (stop_) return false; n.lhs()-accept(*this); if (stop_) return false; n.rhs()-accept(*this); return !stop_; }这能解决一部分问题但“检查 stop_”的样板代码又会散落各处。我后来做了一个更可控的改造让访问接口返回一个三态结果VisitResult取值分别为Continue、Skip、Stop遍历骨架统一按返回值决策Continue正常访问子节点Skip不进入当前节点的子树Stop终止整个遍历。每个 visit 函数根据自己的语义返回不同的控制信号短路逻辑从具体操作里剥离出来收敛成遍历策略的一部分。有一点特别容易写错Stop信号必须从内层一路透传到最外层每一层父节点的骨架代码都要检查返回值并把这个“停”继续向上传。透传逻辑写错不会有编译错误只会表现为遍历停在了错误的位置排起来相当费时间值得先用一组小用例把行为钉死。4. 真实项目里的访问者维护经验设计和技巧讲完这部分是更接近“项目现场”的内容。访问者模式在小 demo 里永远好看放到真实项目里才会暴露维护成本。4.1 新增节点类型那一刻编译器的债主上门典型场景表达式系统本来只有Literal和Add现在产品要求支持一元负号给体系加一个Negate。经典访问者路线上你马上会面对一堆 visitor 类每一个都要补visit(const Negate)。如果你用的是纯虚函数接口编译器会强制列出所有漏网之鱼这其实是好事但如果基类里提供了空实现编译能过运行起来行为就可能不对了。我后来发现一个更可控的工作流在新节点加入之前先全局搜一下所有继承自Visitor的类把它们的数量和维护成本拍个照。如果发现 visitor 类的数量已经失控那这往往是“访问者太多”的信号说明某种系统性的重构比单纯加类型更值得做。操作类一旦超过十几个每次新增节点都像过刑场。在 variant 路线上给std::variant类型列表加Negate同样会触发所有 visit 调用点的编译错误但这种错误更集中每个 visit 的地方都必须穷尽处理所有类型漏一个就不可能编译通过。这比“基类有默认空实现”要安全得多。所以选型时我会先问一句这个 AST 体系里的类型集合未来一年内真的会变吗如果答案是“基本不会”直接上 variant如果答案是“会有第三方扩展”那就老实回去写经典 visitor并用代码审查保证不出现默认忽略。4.2 类型擦除封装访问者让调用方忘记 Visitor 的存在访问者具体类如果直接暴露给上层逻辑接口会非常啰嗦调用方要构造一个 visitor然后手工 accept再读取 visitor 里的结果。比如Evaluator eval; root.accept(eval); double result eval.result();三步走还算能忍但如果十几个操作都这么裸奔代码很快就充满了 visitor 的细节。更关键的是调用方本不应该知道“我调的是访问者”这层机制。我常用的封装方式是把每个算法包装成一个普通函数内部用局部访问者实现double evaluate(const Expr root) { Evaluator eval; root.accept(eval); return eval.result(); }如果操作本身是一个闭包还可以写一个通用的apply辅助函数接收一个函数对象把节点类型分派包在内部。用现代 C 写起来很像把 visitor 变成了std::function集合templatetypename Fn void apply(const Expr root, Fn fn) { struct Adapter : Visitor { Fn fn; void visit(const Literal n) override { fn(n); } void visit(const Add n) override { fn(n); } } adapter{fn}; root.accept(adapter); }做完这层封装后业务代码里的访问者痕迹会大幅减少调用方只看到函数和回调对象访问者的具体策略后续可以替换成std::visit实现而不影响外部接口。这个封装模式在我重构过的几套 AST 工具里都很有价值属于“低成本、高收益”的维护技巧。4.3 三种实现的实测数据与我的选型建议我在一个用于教学的解释器模拟项目 X 里先后用三种写法实现过同一个表达式求值器经典 visitor、std::variant std::visit、以及手写枚举判别式节点对象里内置一个 type tag然后直接 switch static_cast。节点类型约有五种测试是构建一批中等深度的表达式并反复求值。基准代码和优化级别会影响结论下面的数字只给量级参考但趋势是明确的实现方式分派机制相对速度维护体验经典 Visitor两次虚函数调用基准略慢类型开放好加操作但要维护一堆类std::visit索引分派/跳转通常略快 10%~20%类型封闭、穷尽检查强手写 switch type tag单次分支最快最灵活但最脆弱新增类型易漏最让我意外的是手写 switch 在节点类型少时并没有快出一个数量级只快了一个零头但维护成本却是最高的每次加节点都要把散落在各处的 switch 找全漏一处就是很难复现的运行时问题。所以性能不是选型的唯一标准准确性和开发效率往往更重要。综合下来我的选型偏好是这样的类型集合封闭、操作会在生命周期里不断增加首选std::variant std::visit类型集合可能被外部扩展插件框架、自定义节点体系用经典访问者并坚持基类不做静默忽略类型只有两三种、操作也不多不要用访问者直接一个普通函数配合枚举判断就够了。访问者模式的价值在于隔离变化方向。当你只有一套变化方向时它带来的抽象成本大概率是纯开销。与其把访问者当成万能模板不如先做一次变化方向盘点再决定。我自己的使用体会是访问者模式是一门“真香与真臭并存”的技术在类型封闭的多态结构里它让新增操作变得干净利落在类型不停扩张的体系里每一次新增节点都是对它设计局限的提醒。如果你正在维护一个到处都是Visitor的代码库不妨先统计一下 visitor 类的数量和扩展方向再用上面几种思路重新审视一遍——该换std::visit的就别恋战该做类型擦除封装的尽早封一层。最后留一个小技巧无论走哪条路线给访问者体系的“穷尽性”写一个编译期自检测试专门对着新增类型和新增操作两个方向做回归这能省掉很多运行时才暴露的半截实现问题。
RELATED

相关推荐

Testbench模板化设计:VHDL与Verilog仿真验证的高效实践

Testbench模板化设计:VHDL与Verilog仿真验证的高效实践

写完一个数字模块,RTL仿真一跑,波形拉出来一看——空的。这不是芯片没工作,是你的Testbench压根没写好。我在实验室带过的几个师弟,几乎人人都有过这个阶段:RTL写得飞快,一说到写Testbench就随便拉几个信号…

📅 2026/10/16 12:19:13
金融服务业系统设计的核心挑战与实践原则

金融服务业系统设计的核心挑战与实践原则

我无法根据当前输入生成符合要求的博文内容。原因如下:输入中仅提供了项目标题"financial-services",未提供任何实质性的项目正文、关键词列表或摘要描述;所谓“相关热搜词”和“最新网络热词”部分为空,未给出具体词汇…

📅 2026/10/16 12:19:13
第 5 章 网关层与通信基础

第 5 章 网关层与通信基础

📌 本章目标:理解网关层的作用,学会设计统一的通信抽象层 ⭐⭐⭐ 难度:进阶基础 | ⏱️ 预计时间:1.5 小时5.1 为什么需要网关层? 先想一个问题 如果让你直接在组件里调用接口,你可能会这么写&a…

📅 2026/10/16 12:19:13
MORE NEWS

更多资讯

📰

K8s部署Redis生产级实战:持久化、高可用与Helm一站式避坑

聊到k8s安装redis,很多人第一反应是“拉个镜像、起个Deployment不就完事了”。真上手操作你会发现,光是把Pod跑起来只算幼儿园水平,数据不丢、重启不挂、主从能自动切换才是真正的考验。去年某项目上线前,我们所有服务共用一个Red…

📰

QCustomPlot瀑布图绘制详解:从QCPColorMap到实时滚动实现

简介:基于Qt与QCustomPlot的瀑布图实现demo,主要面向需要在Qt界面中展示数据变化过程的C开发者。demo通过随机数生成数据,完整演示了初始化绘图控件、配置坐标轴与刻度、创建QCPGraph对象、绘制矩形及连接线、调用replot刷新视图等关键流程&a…

📰

公共文化场馆携程评论爬取实战:反爬应对与文旅数据应用

1. 项目概述:为什么一个城市图书馆的评论数据值得被系统性采集?“广州图书馆——携程评论爬取”这个标题乍看有点违和——图书馆不是政务服务平台或公共文化场馆吗?怎么会出现在携程这样的旅游预订平台上?但正是这种“反常识”的组…

📰

用Go实现GitLab pre-receive钩子:强制规范commit message

简介:这是一份采用 Go 语言编写的 GitLab pre-receive(预接收)钩子示例资源,面向 GitLab 管理员、DevOps 工程师及对服务端钩子机制感兴趣的开发者。它演示了如何在代码推送前拦截提交,通过检查最新提交消息是否包含指…

📰

PySOT评估必装pysot-toolkit:Ubuntu 16.04编译安装与评测原理详解

1. 为什么必须单独安装 pysot-toolkit:PySOT 主库与评估工具的职责边界 很多人在跑通 PySOT 训练流程后,会自然地认为“模型训出来了,接下来直接 eval 就行”——结果在终端敲下 python tools/test.py 时,十有八九会遇到 Modul…

📰

福克斯刷机避坑:ELM327改AS-Built配置与备份恢复全攻略

简介:面向福特福克斯车主与汽车电子爱好者的电子控制单元刷机工具包,以ELM327诊断接口为硬件基础,可读取车辆电脑数据、分析参数并修改固件。压缩包共33个文件,集成FoCCCus主程序、19个vbf车型固件、12个动态链接库及1个xml配置说…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬