访问者模式的核心是双分派,用于对象结构稳定但需频繁添加新操作的场景;它通过accept()和visit()两次虚调用模拟双分派,要求accept()为虚函数且各节点重写,visit()按具体类型重载并统一用const T&参数,避免const错误与状态污染。

访问者模式的核心是「双分派」,不是继承多态
它解决的问题很具体:当对象结构稳定(比如 ASTNode 类型树不变),但需要频繁添加新操作(比如打印、类型检查、代码生成)时,避免反复修改每个节点类。C++ 没有原生双分派支持,得靠 accept() + visit() 两次虚函数调用模拟。
常见错误是把 Visitor 写成普通工具类,漏掉 visit() 的重载声明,或者在 accept() 里直接调用 visitor.visit(this) —— 这会绑定到基类指针类型,失去多态性。
-
accept()必须是虚函数,且每个具体节点都要重写,显式调用visitor.visit(*this) -
visit()在访问者中按具体节点类型重载,参数类型必须是具体类(如const BinaryOpNode&),不能只写基类引用 - 如果节点类型太多,
Visitor接口会膨胀;可考虑用std::variant+std::visit替代(C++17 起),但丢失了开闭原则的纯粹性
如何避免 const 正确性翻车
访问者常用于只读遍历(比如语义分析),但 C++ 成员函数默认非 const,容易在 visit() 里意外修改节点状态。更隐蔽的问题是:若 accept() 声明为 const,而 visit() 参数是非 const 引用,编译器会报错 —— 因为 this 是 const 指针,解引用后传给非 const 引用不合法。
- 统一用
const T&作为所有visit()参数类型(T是具体节点类) -
accept()函数本身应声明为const,除非操作确实要改节点(如优化器) - 如果真需要修改,把
visit()参数改成T&,同时确保调用方传入的是非常量对象 —— 但多数场景下,这反而暴露了设计问题
std::any 或 std::variant 能替代访问者吗
能简化简单场景,但会丢掉类型安全和编译期检查。比如用 std::variant<binaryopnode unaryopnode numbernode></binaryopnode> 存节点,再用 std::visit 分发,看起来更短:
立即学习“C++免费学习笔记(深入)”;
std::visit([](auto& node) { /* 处理逻辑 */ }, node_variant);但问题立刻出现:无法在访问过程中累积状态(比如符号表),也不能让不同访问者共享同一套遍历逻辑;更关键的是,一旦新增节点类型,所有 std::visit lambda 都得补分支,违反开闭原则。
-
std::variant适合节点类型少、操作简单、且不需跨访问者复用遍历的场景 - 访问者模式真正价值在于把「谁遍历」和「做什么」彻底分离,比如同一个
CodegenVisitor可以被不同 AST 树复用 - 混合使用可行:用
std::variant表示叶子节点,访问者处理复合结构 —— 但别指望它完全取代模式本意
虚函数开销和模板化访问者的取舍
传统访问者靠虚函数实现双分派,每次 accept() + visit() 至少两次虚调用。高频遍历(如解释器执行循环)可能成瓶颈。有人用 CRTP 模板化访问者绕过虚表:
template<typename Derived> struct Visitor { void visit(const BinaryOpNode& n) { static_cast<Derived*>(this)->do_visit(n); } };但这让访问者必须提前知道所有节点类型,破坏了「新增操作无需改节点」的初衷;而且模板实例化会让二进制体积膨胀。
- 虚函数开销在绝大多数场景下可忽略,优先选清晰的虚函数版本
- 只有在 profiler 明确指出
visit()是热点,且节点类型固定不变时,才考虑模板方案 - 别为了理论性能提前优化:访问者本身是为可维护性服务的,过度工程化反而让后续加新操作变得更麻烦
最易被忽略的一点:访问者对象的状态管理。很多人把临时变量塞进 Visitor 成员里,结果并发遍历时出问题 —— 访问者应该无状态,或明确用 thread_local 隔离,否则 debug 起来比虚函数慢十倍还难定位。










