多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

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

C++访问者模式实战:双重派发解耦对象结构与操作 上个月我接手一段遗留代码某个数据模型类的处理逻辑被写成了“分支式大熔炉”一个入口函数内部用连续的类型判断选择不同处理流程。第一次加新类型时我还能靠搜索补上几处分支第二次加时我补了十几处到第三次我在函数里翻了三分钟才找到该改的位置。那天晚上我决定彻底重构把处理逻辑从对象结构里拆出来。这就是我对C访问者模式由路转粉的开始。如果你也遇到过“每种类型都要做不同处理但处理逻辑又多又杂塞进类里又臭又长”的场景这篇文章会适合你。我尽量不写教科书里那种纯概念复述而是基于我自己踩过的坑和最终沉淀下来的代码方案聊聊访问者模式在C里的真实模样。1. 为什么最终会走到访问者模式一次痛苦重构的复盘先说结论访问者模式做的事是把“作用于某种数据结构上的操作”从数据结构中解耦出去。数据类型只负责维持自身结构至于输出、统计、校验、生成代码这些行为全部外包给独立的外部访问者借助双重派发机制找到“当前对象对应的正确处理函数”。1.1 没有访问者模式时的典型乱象我看过一段真实代码核心数据结构是一组语法节点原本做格式化输出。刚开始只有三种节点变量节点、常量节点、二元表达式节点。处理起来很直接void formatNode(const NodeBase* node) { if (const auto* v dynamic_castconst VarNode*(node)) { formatVar(v); } else if (const auto* c dynamic_castconst ConstNode*(node)) { formatConst(c); } else if (const auto* b dynamic_castconst 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_ptrconst NodeBase lhs, std::shared_ptrconst 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_ptrconst NodeBase lhs_; std::shared_ptrconst 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::stackdouble。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::stackdouble 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_ptrconst 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是不是更好的替代受C17影响很多场景下std::variantstd::visit能做同样的事而且代码更短。比如表达式树如果不在运行时扩展节点集合完全已知就可以用variantusing Expr std::variantNum, 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_ptrconst Expr或unique_ptr包一层否则类型不完整访问者内部又得小心处理递归调用编译错误信息也远比虚函数调用难读。我见过不少同事在这上面花了一个下午追模板错误最后悻悻改回经典访问者。从我的实践来看新项目里我更倾向于先问一句“这个类型集合会不会变”。如果不会变且想快速拿到清晰的分派代码用variant如果会变但操作很频繁比如又做格式化、又做类型推导、又做校验用经典访问者如果既会变又操作多我会重新审视设计通常会用默认访问者非纯虚函数兜底而不是让双方硬扛。6. 关于访问者我最后想说的几句大实话访问者模式确实能解决一类很典型的结构与行为耦合问题但它不是那种“用了就高级”的模式。我见过太多为了模式而模式的重构节点只有三四个操作也不复杂非要硬套访问者结果两个访问者接口加上去代码量翻了一倍可读性还下降了。个人体会是当操作函数已经超过六个、且节点总数相对稳定时访问者模式才真的开始划算如果只有两三个处理动作直接写重载函数或if/else反而更直观。这段摸索给我的最大启发是模式和工具一样都要先确认问题维度匹配。C里访问者的本质是“用一个虚函数模拟出双重派发”模拟就有成本成本来自间接调用和维护协议。只要你能清楚回答“类型集合是否稳定”“操作集合是否开放”“是否要跨层只读遍历”这三个问题选不选、怎么选就都不是运气问题了。以后再见着visit(NodeVisitor)这种接口希望你别像我在代码里翻了三分钟才知道该改哪里。搞清楚它背后的派发逻辑、控制好头文件依赖、按需决定默认访问者这东西在你的工具库里确实是张好牌。
返回列表