news 2026/10/18 11:44:04

C++访问者模式实战:双重派发解耦对象结构与操作

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++访问者模式实战:双重派发解耦对象结构与操作

上个月我接手一段遗留代码,某个数据模型类的处理逻辑被写成了“分支式大熔炉”:一个入口函数,内部用连续的类型判断选择不同处理流程。第一次加新类型时,我还能靠搜索补上几处分支;第二次加时,我补了十几处;到第三次,我在函数里翻了三分钟才找到该改的位置。那天晚上我决定彻底重构,把处理逻辑从对象结构里拆出来。这就是我对C++访问者模式由路转粉的开始。

如果你也遇到过“每种类型都要做不同处理,但处理逻辑又多又杂,塞进类里又臭又长”的场景,这篇文章会适合你。我尽量不写教科书里那种纯概念复述,而是基于我自己踩过的坑和最终沉淀下来的代码方案,聊聊访问者模式在C++里的真实模样。

1. 为什么最终会走到访问者模式:一次痛苦重构的复盘

先说结论:访问者模式做的事,是把“作用于某种数据结构上的操作”从数据结构中解耦出去。数据类型只负责维持自身结构,至于输出、统计、校验、生成代码这些行为,全部外包给独立的外部访问者,借助双重派发机制找到“当前对象对应的正确处理函数”。

1.1 没有访问者模式时的典型乱象

我看过一段真实代码,核心数据结构是一组语法节点,原本做格式化输出。刚开始只有三种节点:变量节点、常量节点、二元表达式节点。处理起来很直接:

void formatNode(const NodeBase* node) { if (const auto* v = dynamic_cast<const VarNode*>(node)) { formatVar(v); } else if (const auto* c = dynamic_cast<const ConstNode*>(node)) { formatConst(c); } else if (const auto* b = dynamic_cast<const BinaryExprNode*>(node)) { formatBinary(b); } else { throw std::runtime_error("unknown node type"); } }

这种写法的核心问题不是“类型检查太多”,而是所有操作都要改这一个函数。等到你要做类型检查、代码生成、AST可视化时,就得复制出三个满屏分支的函数。再往后的某天,有人往里加了函数调用节点,于是你需要在每一个分支函数里都补上对应代码,漏一处就是运行时崩溃。

1.2 访问者模式真正帮你解决的三个问题

  • 单一职责:节点类只保留结构字段和基本访问能力,业务逻辑不再污染数据结构。
  • 开放封闭:在不改动既有类型的前提下,新增一个访问者即可增加一种新操作,符合“对扩展开放、对修改关闭”的原则。
  • 类型安全的遍历:访问者借助编译期重载,将节点分派到正确函数,比值对式的dynamic_cast链更清晰,遇到未处理类型也能快速定位。

所谓“接受访问”accept接口,其实是给每个节点类开了一个口子:外部逻辑进来时,节点自己调用对应访问者接口,这个反向调用就是双重派发的另一半。节点不关心访问者怎么计算,访问者不关心节点内部结构,二者只通过共同的接口约定协作。

2. 双重派发原理:真正理解虚函数与函数重载的边界

C++里的普通虚调用,一次只处理一个动态类型——“我调用谁的方法”由对象的真实类型决定。函数重载则完全发生在编译期,编译器根据声明的静态类型选择函数版本。访问者模式最难理解的地方就在这里:重载想动态选择,但C++默认不给这个机会,所以必须由对象自己把真实类型“告诉”访问者。

2.1 为什么需要反向调用“倒打一耙”

如果直接这样写:

void process(const NodeBase& node) { visitor.visit(node); // 这里永远是调用 visit(NodeBase) }

因为visitor.visit的重载解析发生在编译期,参数静态类型是NodeBase&,编译器只看得见visit(const NodeBase&),看不出运行时的具体类型。只有让node自己再虚调用一次,才会因为node的动态类型不同而触发不同的虚函数。这就是accept接口存在的价值:

class NodeBase { public: virtual ~NodeBase() = default; virtual void accept(NodeVisitor& v) const = 0; };

然后在每个具体节点里实现:

void VarNode::accept(NodeVisitor& v) const { v.visit(*this); // this的真实类型是 VarNode* }

这次visit(*this)的重载决议发生在编译期,但this的真实类型来自虚函数分派的结果。一个虚调用,接了另一个重载决议,形成双重派发。

2.2 为什么是“访问者”而不是“访问者模式”这么别扭

总有读者问:明明靠虚函数就能实现不同类的不同操作,为什么还要搞出一个visit方法再绕一圈?原因是“不同类的不同操作”这句话,在C++里默认只能选择“类”这一维动态分派。当你想要的不仅是“不同类”之间的区别,而是**不同类型组合(双参数组合、多参数组合)**的动态行为时,普通虚函数就失效了。访问者模式是“模拟双重派发”的一种手段:访问者类型提供一组重载,节点类型通过accept选择重载版本。虽然间接,但能把“操作”和“数据结构”之间的交叉影响降到最低。

2.3 一味使用访问者模式的代价

理解原理之后,自然会看到代价:每个具体节点类都要实现accept,每增加一种节点,所有既有访问者都要增加对应visit重载;如果访问者没有该节点的处理函数,编译器不会报错,跑起来却可能在纯虚函数上崩溃。这个代价在类型集合稳定的场景里完全可控,在类型频繁变动的场景里就成了灾难。

3. 手把手搭建一份可运行的经典访问者骨架

下面给出一份最原始的骨架,先把机制跑通,再谈工程化。

3.1 访问者接口与节点接口

示例场景简化为一个算式树:数字、加法、减法。

// 前置声明:因为访问者接口里要用到具体节点的指针 class NumNode; class AddNode; class SubNode; class NodeVisitor { public: virtual ~NodeVisitor() = default; virtual void visit(const NumNode& node) = 0; virtual void visit(const AddNode& node) = 0; virtual void visit(const SubNode& node) = 0; }; class NodeBase { public: virtual ~NodeBase() = default; virtual void accept(NodeVisitor& v) const = 0; };

3.2 具体节点实现

每个具体节点只需要做一件机械事:把自身真实类型传给访问者。但要注意这里的递归结构——加法节点持有左右子节点,它需要先把访问者分派给子节点,再把自己交给访问者。

class NumNode : public NodeBase { public: explicit NumNode(int value) : value_(value) {} void accept(NodeVisitor& v) const override { v.visit(*this); } int value() const { return value_; } private: int value_; }; class AddNode : public NodeBase { public: AddNode(std::shared_ptr<const NodeBase> lhs, std::shared_ptr<const NodeBase> rhs) : lhs_(std::move(lhs)), rhs_(std::move(rhs)) {} void accept(NodeVisitor& v) const override { lhs_->accept(v); rhs_->accept(v); v.visit(*this); } const NodeBase& lhs() const { return *lhs_; } const NodeBase& rhs() const { return *rhs_; } private: std::shared_ptr<const NodeBase> lhs_; std::shared_ptr<const NodeBase> rhs_; };

这里AddNode::accept是后续所有复杂访问者写作的关键:遍历顺序决定访问者的逻辑是否正确。如果先访问自己再访问子节点,对某些“先收集子节点再计算”的场景就会出错。我建议所有访问者都把“先深入子树、再回头处理当前节点”当作默认约定,除非你有明确理由反向遍历。

3.3 第一个访问者:计算表达式数值

class Evaluator : public NodeVisitor { public: double result() const { return result_; } void visit(const NumNode& node) override { result_ = node.value(); } void visit(const AddNode& node) override { result_ += node.lhs_getter(); // 示例,实际上要递归访问后才能拿到结果 } void visit(const SubNode& node) override { // 类似处理 } private: double result_ = 0.0; };

这段代码有个明显的设计缺口:AddNode没有直接给出数值,而是持有子节点,访问者必须递归计算左右子节点后再求和。我们可以在visit(const AddNode& node)内部创建两个子求值器分别访问左右子树,但这会引入临时对象,也不优雅。更常用的做法是让访问者内部维护一个显式的栈或者结果队列。

3.4 用栈或返回值化解决“结果传递”

我自己通常这么改:访问者内部维护一个std::stack<double>。

class Evaluator : public NodeVisitor { public: void visit(const NumNode& node) override { value_stack_.push(node.value()); } void visit(const AddNode& node) override { node.lhs().accept(*this); node.rhs().accept(*this); double rhs = value_stack_.top(); value_stack_.pop(); double lhs = value_stack_.top(); value_stack_.pop(); value_stack_.push(lhs + rhs); } void visit(const SubNode& node) override { node.lhs().accept(*this); node.rhs().accept(*this); double rhs = value_stack_.top(); value_stack_.pop(); double lhs = value_stack_.top(); value_stack_.pop(); value_stack_.push(lhs - rhs); } double result() const { if (value_stack_.empty()) return 0.0; return value_stack_.top(); } private: std::stack<double> value_stack_; };

这样处理有个隐含约定:访问者访问节点时,节点自己会先递归访问子树,等回到当前节点时,值栈里已经放着左右子树的结果了。我把这种模式叫“后序遍历思路”,它在解析树、表达式计算、代码生成里非常通用。不要试图把求值结果存到节点上,那会破坏const语义,也会污染数据结构。

4. 实际项目中反复踩的坑:const、循环依赖、返回值和扩展困境

光跑通骨架不难,难在把它放到一个正在演进的工程里。我整理几个最有代表性的痛点。

4.1 头文件循环依赖:接口定义顺序比你想得更敏感

访问者接口里需要包含具体节点类的引用类型参数,而具体节点类又需要访问者接口的完整定义来实现accept(NodeVisitor&)。这造成经典的头文件环:访问者头文件include节点头文件,节点头文件又include访问者头文件。

解决办法只有两个方向:

  • 方向一:访问者头文件前置声明具体节点类,成员函数使用引用/指针,实现移到cpp文件后再include节点完整定义。这种方案适合访问者接口较稳定、不频繁改动的情况。
  • 方向二:所有具体节点和访问者接口都放在同一个头文件里,接受耦合。这个方案我只推荐给节点集合很小且几乎不变的项目。

我在某模拟代码生成项目里用的是方向一。节点类的公共头文件只声明NodeBase、NodeVisitor和各个节点的accept;访问者实现文件里去include完整节点定义,因为那里需要访问节点的数据成员。看起来多绕了一次,但编译依赖被拉直了:节点类头文件不依赖访问者实现,访问者实现依赖节点类头文件。

4.2 accept的const版本与访问者中的const语义

很多新手会在节点接口里只写virtual void accept(NodeVisitor&),忘了加const。当你想对一棵只读的树做分析时,就得把整棵树的shared_ptr<const NodeBase>都转成非const,或者被迫添加const_cast,体验极差。我建议节点接口直接定成:

virtual void accept(NodeVisitor& v) const = 0;

访问者接口里也用const NumNode&之类参数。这样既能访问只读树,也能在访问者内部只做只读分析。如果某次处理确实要修改节点,再做一次const_cast,至少从类型上明确标出了“这次修改是例外”。

4.3 返回值不是访问者模式天然具备的能力

初次使用的人,最容易在“访问者怎么返回结果”上卡住。visit接口天然返回void,理由也很简单:访问者通常要遍历一整棵结构,返回单个值代表“只处理了一个节点”,不符合它的定位。我的经验是分三种情况处理:

  • 结果为单值:访问者内部维护一个成员变量,所有visit实现都往这个变量上叠加,事后用result()取结果。
  • 结果为集合:访问者内部维护std::vector或std::unordered_map,每个visit处理自己的分支时向集合里追加。
  • 结果必须分层:使用栈或队列,配合遍历顺序来组织中间结果。

大多数实际场景用“栈+后序”就足够了。我在一个镜像优化工具里用这套方式做了十几轮遍历,从来没觉得非要返回复杂的对象不可。

4.4 访问者与节点同步进化:唯一的“修改点”

访问者模式最难受的地方,是新增一个具体节点时改动面很大。每加一个节点类,所有既有访问者接口都要增加一个visit(const NewNode&)纯虚函数,不改就编译失败;如果不想编译失败,就得提供默认实现。这本质上是“开放封闭原则”的交换:你换来的是新增操作零改动,代价是新增类型成本显著。

我后来总结出两类策略:

  • 访问者接口里给每个visit提供默认空实现,而不是纯虚函数。它牺牲了“漏处理保错误”,换来了“加节点不炸访问者”。配合一个unhandled()钩子函数,在调试期统计未处理节点比运行期崩溃友好很多。
  • 把访问者拆成基础访问者和扩展访问者两层:基础访问者负责公共遍历逻辑,扩展访问者只重载自己关心的节点。我在做AST局部重写时就用这个方案,局部访问者只需要处理少数几类节点,其余节点自动走默认遍历。
class DefaultVisitor : public NodeVisitor { public: void visit(const NumNode&) override {} void visit(const AddNode& node) override { node.lhs().accept(*this); node.rhs().accept(*this); } void visit(const SubNode& node) override { node.lhs().accept(*this); node.rhs().accept(*this); } }; class VariableCollector : public DefaultVisitor { public: void visit(const NumNode& node) override { // 只对数字节点感兴趣 } };

这方案极大降低了访问者维护成本,也保留了面向扩展的核心价值。

5. 现代C++视角:std::variant与std::visit是不是更好的替代

受C++17影响,很多场景下std::variant+std::visit能做同样的事,而且代码更短。比如表达式树如果不在运行时扩展,节点集合完全已知,就可以用variant:

using Expr = std::variant<Num, Add, Sub>; struct Evaluator { double operator()(const Num& n) const { return n.value; } double operator()(const Add& a) const { return std::visit(*this, *a.lhs) + std::visit(*this, *a.rhs); } double operator()(const Sub& s) const { return std::visit(*this, *s.lhs) - std::visit(*this, *s.rhs); } };

没有循环依赖,没有虚函数accept,没有访问者接口的纯虚函数,编译期直接确定所有重载。而且std::visit是真正的编译期派发,性能通常比虚函数分派更好,处理”未匹配类型“时也更早暴露问题。

什么时候我会果断用std::variant?典型特征是:

  • 类型集合固定且不会频繁变动;
  • 不需要在二叉树之外再嵌套跨模块的用户自定义类型;
  • 操作很多,但操作总数没有失控;
  • 团队已经熟悉variant和泛型lambda写法。

什么时候不用?当你必须持有抽象基类指针、需要协调多个模块各自扩展类型、或者项目里到处都是旧式多态接口时,硬把节点换成variant反而要大动干戈。另外variant写递归结构时比较烦人:必须用std::shared_ptr<const Expr>或unique_ptr包一层,否则类型不完整;访问者内部又得小心处理递归调用,编译错误信息也远比虚函数调用难读。我见过不少同事在这上面花了一个下午追模板错误,最后悻悻改回经典访问者。

从我的实践来看,新项目里我更倾向于:先问一句“这个类型集合会不会变”。如果不会变,且想快速拿到清晰的分派代码,用variant;如果会变,但操作很频繁,比如又做格式化、又做类型推导、又做校验,用经典访问者;如果既会变又操作多,我会重新审视设计,通常会用默认访问者+非纯虚函数兜底,而不是让双方硬扛。

6. 关于访问者我最后想说的几句大实话

访问者模式确实能解决一类很典型的结构与行为耦合问题,但它不是那种“用了就高级”的模式。我见过太多为了模式而模式的重构:节点只有三四个,操作也不复杂,非要硬套访问者,结果两个访问者接口加上去,代码量翻了一倍,可读性还下降了。个人体会是,当操作函数已经超过六个、且节点总数相对稳定时,访问者模式才真的开始划算;如果只有两三个处理动作,直接写重载函数或if/else反而更直观。

这段摸索给我的最大启发是:模式和工具一样,都要先确认问题维度匹配。C++里访问者的本质是“用一个虚函数模拟出双重派发”,模拟就有成本,成本来自间接调用和维护协议。只要你能清楚回答“类型集合是否稳定”“操作集合是否开放”“是否要跨层只读遍历”这三个问题,选不选、怎么选就都不是运气问题了。

以后再见着visit(NodeVisitor&)这种接口,希望你别像我在代码里翻了三分钟才知道该改哪里。搞清楚它背后的派发逻辑、控制好头文件依赖、按需决定默认访问者,这东西在你的工具库里确实是张好牌。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 11:43:10

Python模块化:把代码拆成乐高积木,实现松耦合与高复用

“改了一行配置&#xff0c;整个服务全崩了”……这种场面我在不少项目里见过。尤其当你接手一段几百上千行的单文件脚本&#xff0c;牵一发动全身&#xff0c;改个数据格式都要翻遍全文件。那份痛苦&#xff0c;相信很多写Python的人都有共鸣。我自己的体会是&#xff0c;Pyth…

作者头像 李华
网站建设 2026/10/16 8:58:29

REA模型:用资源、事件和参与者重构业务领域建模

做企业系统设计这些年&#xff0c;每次和人讨论业务建模&#xff0c;绕不开一个缩写&#xff1a;REA。这三个字母对应 Resource&#xff08;资源&#xff09;、Event&#xff08;事件&#xff09;、Agent&#xff08;参与者&#xff09;&#xff0c;是会计信息系统领域里一套经…

作者头像 李华
网站建设 2026/10/15 5:36:59

Jmeter接口测试验证码登录全攻略:短信、图形验证码与Token关联

做接口测试的人&#xff0c;迟早会撞上验证码这道墙。Jmeter脚本写得好好的&#xff0c;一到登录接口就卡住&#xff0c;验证码拿不到&#xff0c;登录进不去&#xff0c;后面所有业务流程全部瘫痪。这不是脚本问题&#xff0c;而是验证码本身就是一个反自动化设计。我当年第一…

作者头像 李华
网站建设 2026/10/16 11:44:02

Vue2/Vue3+UniApp实现移动端无限滚动加载实战指南

在移动端做信息流列表&#xff0c;翻页加载是最常见的交互&#xff0c;但传统分页那种点按钮跳转的体验&#xff0c;放到手机上一用就露馅儿——用户划着划着突然被拦下来&#xff0c;点个“下一页”&#xff0c;页面哗啦跳一下&#xff0c;阅读节奏全断了。无限滚动加载就是为…

作者头像 李华
网站建设 2026/10/17 12:47:04

谷歌关停免费模型,开发者迁移实战与多模型路由方案

宋体 宋体1. 突发消息背后&#xff0c;先别急着骂谷歌&#xff1a;被关停的到底是什么先说结论&#xff1a;这不只是版本号调整&#xff0c;而是谷歌在免费策略上的一次明显转向。我手上的十几个自动化项目&#xff0c;昨晚开始陆续收到模型404、403和 quota 超限的告警&#x…

作者头像 李华
网站建设 2026/10/17 13:54:40

给Claude装上长期记忆:claude-mem 开源工具实战与踩坑

1. 初见 claude-mem&#xff1a;会话失忆痛点与这个工具的定位大概半年前&#xff0c;我开始重度使用 Claude 处理代码审查和架构梳理。最头疼的一件事不是模型能力不够&#xff0c;而是它记不住人。我上午跟它定了某个模块的命名规范&#xff0c;下午开个新会话它全忘了&#…

作者头像 李华