
如果你在大型项目里拆过 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 内部不是做业务而是把自身类型一次性抛给 Visitorstruct 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_ptrAstNode lhs; std::unique_ptrAstNode rhs; BinaryOp(char o, std::unique_ptrAstNode l, std::unique_ptrAstNode 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::variantdouble, 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_ptrAstNode lhs; std::unique_ptrAstNode rhs; BinaryOp(char o, std::unique_ptrAstNode l, std::unique_ptrAstNode 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_mapstd::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_mapstd::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 一行没动。这就是访问者模式的核心红利操作之间的差异被隔离在各自的类里节点体系本身就是稳定的。使用起来就是一个栈上的临时 Visitorint main() { // 构造表达式: (1 2) * (x - 3) auto expr std::make_uniqueBinaryOp( *, std::make_uniqueBinaryOp(, std::make_uniqueNumber(1.0), std::make_uniqueNumber(2.0)), std::make_uniqueBinaryOp(-, std::make_uniqueVariable(x), std::make_uniqueNumber(3.0)) ); Printer printer(std::cout); expr-accept(printer); // 输出 (1 2) * (x - 3) std::cout \n; std::unordered_mapstd::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_ptrAstNode operand; explicit UnaryMinus(std::unique_ptrAstNode 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 统一保持 constvisit 参数也统一写成 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::vectorconst 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 与 visit5.1 variant 版表达式求值快速上手如果你的类型集合是封闭的那 C17 之后的std::variant std::visit几乎是为这个场景量身定做的替代方案。它把你“类型集合”和“操作集合”的扩展关系暴露得非常清楚。比如同样做一个表达式系统用 variant 定义封闭类型集#include variant struct Num { double value; }; struct BinOp { char op; std::unique_ptrExprNode lhs; std::unique_ptrExprNode rhs; }; // 递归 variant 的常规写法用一个中间包装 struct ExprNode; struct ExprHolder { std::unique_ptrExprNode node; }; struct ExprNode { std::variantNum, BinOp, ExprHolder data; }; using Expr std::unique_ptrExprNode;然后在访问侧写一个std::visit利用 C17 的if constexpr做类型分发double eval(const ExprNode e) { return std::visit( [](const auto x) - double { using T std::decay_tdecltype(x); if constexpr (std::is_same_vT, Num) { return x.value; } else if constexpr (std::is_same_vT, 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 更简单、更快、类型更安全。如果你需要开放类型体系或者对象要作为多态指针存在同一容器里比如vectorunique_ptrAstNode那么传统访问者模式更合适。很多网上教程说“用 variant 取代访问者模式”这个说法只对了一半正确说法是“类型封闭时用 variant 取代访问者模式”。5.3 两者混用的实战思路我项目里最常见的是两套方案混用。解析阶段生成的类型集合相对封闭我会用 variant 做快速求值和单元测试一旦要进入多阶段分析比如编译器中段要叠加各种优化处理节点类型体系会不断扩展我会换成继承 访问者模式。切换的边界是“类型集合是否会随着项目演进继续扩大”。还有一种混合姿势节点用继承体系但内部数据用 variant 做状态字段。比如 BinaryOp 节点的操作符类型是std::variantAddOp, SubOp, MulOp外层访问者处理结构内层 visit 处理操作符。这样结构层是开放的具体操作符是封闭的两个维度各自用最省事的方案。这种“模式组合”在成熟项目里很常见你不需要二选一。最后聊点实操体会。我从最开始写访问者模式只会照抄 Shape 例子的accept到后来在真正的解释器项目里大规模使用它最大的感受是这个模式的价值不在于代码里出现了 Visitor、accept 这些词而在于你找准了“类型稳定、操作易变”这个边界。那套求值器代码后来加了类型检查、打印生成、符号表统计等五六个 Visitor节点类几乎没有因为新增操作而改动过这种体验真的很爽。但如果让我给新项目做技术选型我会先问一句这个类型集合真的是开放的吗如果不是std::variant 多半更香。判断得准比姿势帅重要得多。