很多C++开发者对访问者模式的最初印象,大概率来自《设计模式》那本书里的经典图示,或者面试八股文里关于“双分派”的问答。学了之后放进工具箱,却很少真正用起来。我做了十几年C++,真正大规模用上访问者模式是在处理AST、异构容器和消息协议转换这些场景之后,才意识到它的价值远超教科书上那几行例子。这篇东西我想把访问者模式从“认识它”推到“用好它”的层面,聊聊多分派的本质、经典实现里那些容易绊倒人的细节,以及现代C++(尤其std::variant和std::visit)给它带来的新解法,最后用几个我能落地的实战场景做收尾。适合对C++有一定基础、想深入了解模式本质和现场选型的人——如果你正为“要不要用访问者、还是直接用std::visit”纠结,这篇应该能给你一个明确答案。
1. 多分派的本质:访问者模式为什么总被误解
访问者模式在C++社区里口碑两极分化很严重。有人说它丑陋、难维护,有人说它是处理复杂对象结构的神器。我的观点是,骂它的人多半没用对场景,而夸它的人往往也只是套过一两个模板。要真正搞清楚这个模式,得先回到它最核心的动机:运行时多分派。
1.1 虚函数只解决单分派
C++面向对象里,虚函数是最基础的多态手段。但绝大多数时候虚函数只实现了单分派——也就是说,最终调用哪个函数,只取决于某个对象的动态类型。你有一个Shape*,调用draw(),运行时根据它实际指向的是Circle还是Rectangle决定调用哪个实现。这是一个对象的动态类型参与决策,所以叫单分派。
但现实世界的很多逻辑需要两个(甚至更多)动态类型共同参与决策。最经典也最常被提起的例子是碰撞检测。假设你要判断两个形状是否相撞,行为不是由“第一个形状”单独决定的,也不是由“第二个形状”单独决定的,而是由“两者组合”决定的。
用虚函数写两个形状的碰撞,传统解法是两层虚调用:first->collide(second),Circle的collide里再根据second的类型写一堆dynamic_cast分支。这就是典型的“手工分派”,又臭又长,而且每加一个新类型就要改动老代码。
访求者模式正是为这类问题设计的:它把“两个动态类型决定行为”这个复杂问题拆成两次单分派,第一次由被访问元素的类型决定调用哪个重载,第二次由访问者的类型决定具体进入哪个分支。
code复制先别急着背结论,记住这句话:访问者模式不是遍历器,不是迭代器的替代品,它解决的是分派问题,遍历只是它常被顺带使用的场景。
1.2 数据与操作分离
访问者模式第二个重要价值是“数据与操作分离”。如果你有一组相对稳定的元素类(例如编译器里的AST节点:IfStmt、WhileStmt、CallExpr),它们的核心结构变化很慢,但针对它们的操作却变化频繁——语义分析、代码生成、格式化、统计行数、死代码消除……
传统面向对象写法是把每个操作分散到各个元素类里,IfStmt里加一个semanticAnalyze(),WhileStmt里也加一个semanticAnalyze(),等到新增一个操作,所有类都要跟着动,改到想吐。
访问者模式的做法反过来:元素类只保留一个accept(Visitor&),把操作算法放进独立的Visitor类里,每个Visitor只负责一种操作。新增操作时,元素类一动不动,只新增一个Visitor类。如果你的元素集合足够稳定、操作集合持续膨胀,这个方向的收益是特别明显的。
1.3 从几何对象到AST:两种典型场景的差别
访问者模式最常见的两类用法,虽然代码结构类似,但设计动机有点不一样。
第一种是几何/图形系统,类似Shape/Element的例子。这类场景的出发点是多分派——碰撞检测、相交计算、格式导出,都依赖两个类型的组合。第二种是编译器/解释器,Element是AST节点,Visitor是遍历器或者分析器。这类场景的出发点是操作分离——AST节点类型稳定,但各种编译pass不断涌现。
如果你现在碰到的场景两者都不占(元素类型频繁变化、操作数量也很少,或者分派根本不依赖多类型组合),那访问者模式大概是错的工具。别硬套,工具箱里别的锤子也许更合适。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典实现里的坑:Accept、返回值、循环依赖
聊完模式背后的本质,接下来进入实操层面。访问者模式教科书般的实现框架并不复杂,但一进入真实项目,你会发现很多细节并不像书里讲得那么丝滑。
2.1 最小可运行骨架
先给一个传统继承体系下的最小骨架,这是所有讨论的基础。
cpp复制#include <iostream>
#include <memory>
#include <vector>
class Visitor;
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor& v) = 0;
};
class ConcreteElementA;
class ConcreteElementB;
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(ConcreteElementA& a) = 0;
virtual void visit(ConcreteElementB& b) = 0;
};
class ConcreteElementA : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
// A特有的接口
};
class ConcreteElementB : public Element {
public:
void accept(Visitor& v) override {
v.visit(*this);
}
// B特有的接口
};
class PrintVisitor : public Visitor {
public:
void visit(ConcreteElementA& a) override {
std::cout << "A" << std::endl;
}
void visit(ConcreteElementB& b) override {
std::cout << "B" << std::endl;
}
};
int main() {
std::vector<std::unique_ptr<Element>> elements;
elements.push_back(std::make_unique<ConcreteElementA>());
elements.push_back(std::make_unique<ConcreteElementB>());
PrintVisitor printer;
for (auto& e : elements) {
e->accept(printer);
}
return 0;
}
这个骨架解释一下运行机制。accept这层转发是整个模式的枢纽。外层看到的是Element::accept(Visitor&)这个虚函数,这是第一次分派,动态类型决定进入哪个具体元素的accept。进到具体元素的accept之后,v.visit(*this)这里的*this已经是确定的动态类型了,编译器在这里选择一个精确匹配的重载函数——这是第二次分派。两次动态分派叠加,就实现了双分派。
2.2 返回值怎么办
教科书里的Visitor往往都是void visit(...),现实中却经常要返回值。比如你要统计AST节点数量,visit就得返回整数;你要做解释执行,visit得返回求值结果。
经典C++访问者处理返回值有几种土办法。
第一种,把返回值做成Visitor的成员变量。visit执行期间把结果存进this->result,外层访问完再取出来。简单粗暴,但同一个Visitor对象不能重入,多线程下也不能共享一个Visitor实例。
第二种,用模板化的visit。C++不支持虚函数模板,所以这里需要一点小技巧:visit返回一个通用类型(比如std::any或者boost::any),外层再转换成期望的类型。这种做法灵活性高,但类型安全性差,std::any_cast失败会扔异常,代码观感也偏“脏”。
第三种是现在比较推荐的,把访问器本身写成泛型lambda或者一个带模板方法的类。std::visit就是走这条路,后面会专门讲。
cpp复制// 用std::any做返回值,灵活但不安全
class EvalVisitor : public Visitor {
public:
std::any visit(const IntExpr& e) override {
return e.value();
}
std::any visit(const AddExpr& e) override {
auto lhs = visit(e.left());
auto rhs = visit(e.right());
return std::any_cast<int>(lhs) + std::any_cast<int>(rhs);
}
};
实际做解释器时,如果你不想引入std::variant,用std::any过渡也勉强能跑,但类型错误要到运行期才暴露。要在编译期就锁定类型,最简单还是把所有visit的返回类型统一成基类指针ExprResult*,或者封装成一个变体类型。
2.3 循环依赖和前置声明
经典访问者实现中,Element和Visitor互相引用,天然形成了循环依赖。头文件组织稍不注意就陷入include地狱。
一般做法是:Visitor.h里前置声明所有具体Element类,Element.h里前置声明Visitor,然后在实现文件(.cpp)里再真正include对方的头文件。很多初学者在这里会在头文件里直接#include "ConcreteElementA.h",结果就是一套头文件互相包含,编译期爆炸,改一个文件要重编整个工程。
code复制在大型项目里,访问者模式导致的循环依赖问题会被放大。我见过一个模块因为滥用访问者,头文件依赖环越绕越大,最后只能用前置声明+指针到处打补丁。维护到后期,改一个visit重载签名,要好几个模块重新编译。
2.4 const访问者与非常量访问者
访问者还要区分visit(const ConcreteElementA&)和visit(ConcreteElementA&)。如果你的Visitor只做只读分析,应该让visit参数为const引用;如果要做修改,就非const。实际项目里常常两个版本都需要——一个分析器是只读的,一个编译器pass要改写AST。
标准做法是提供两组接口,或者让accept同时支持const和非const重载:
cpp复制class Element {
public:
virtual void accept(Visitor& v) = 0;
virtual void accept(ConstVisitor& v) const = 0;
};
这样你就有了两个访问者基类,对应两种操作模式。代价是接口数量翻倍,如果你还想支持移动语义,还会再翻一层。很多项目扛不住这种接口爆炸,就干脆只做非const版本,需要用const的场景就const_cast,也不是不行,但属于拿脚趾头抠地板,不体面。
3. 现代C++的改写:std::visit是什么关系
C++17带来了std::variant和std::visit,很多人发现用这两个组合可以写出比经典访问者更简洁的代码。于是问题来了:访问者模式是不是过时了?
3.1 std::visit是一种访问者,而且更安全
std::visit本质上是考普·希德(Koppe-Halliday)技术的完整体现。当你调用std::visit(visitor, variant),库会生成一个静态分派表,根据variant当前持有的索引跳转到对应重载。这与访问者模式“把元素类型的分派交给一个外部访问者对象”的思想如出一辙——但它靠着模板机制把分派表提前在编译期排好了,不需要任何虚函数调用。
而且std::visit是穷尽性检查的:如果你给出的visitor没有覆盖variant的全部备选类型,编译直接报错。经典访问者里,如果你忘了在某个子类实现visit,编译器也会报错,但报错信息经常是“这个类是抽象类”,你得从一堆链接错误里猜是哪个类没实现。
cpp复制#include <variant>
#include <iostream>
using Node = std::variant<int, double, std::string>;
int main() {
std::vector<Node> nodes{1, 2.5, "hello"};
auto printVisitor = [](const auto& v) {
std::cout << v << std::endl;
};
for (const auto& n : nodes) {
std::visit(printVisitor, n);
}
return 0;
}
配合泛型lambda,一行代码就能实现一个“无论什么类型都能处理”的访问者。这对新手极其友好:不需要写任何继承体系,不需要前置声明,不需要accept。
3.2 什么时候不该用std::visit
但std::visit不是万能的,它的前提是你把元素类型固定成一个std::variant。这意味着两点:一,元素类不能有虚函数继承体系,你得把一组类型看作平级的备选集合;二,元素类型集合必须是编译期封闭的,因为std::visit要列出全部类型。
如果未来可能出现新的元素类型,而你又不能改动类型集合的定义(比如类型定义在第三方库里),std::visit就卡死了。这种“开放集合”场景,经典访问者的继承体系反而更灵活——新增一个Element子类,只要它实现了accept,所有基于Element基类的容器都能直接遍历它,老Visitor不需要改动。
另一个限制在于状态。std::visit的visitor是随调用传进去的,如果你想在一次遍历中累积状态(比如统计节点总长度),你就得自己用捕获引用、或者把累积器包在lambda里传递。经典访问者模式里,Visitor是一个对象,状态天然就是成员变量,整个遍历过程可以一路更新它。虽然是写作风格的区别,但在复杂嵌套结构里,后者有时候更直接。
code复制综合下来我的结论是:类型集合封闭、元素类型属于你自己的代码库,优先用std::variant + std::visit;元素类型来自第三方并可能扩展,或者你需要一个能跨多次遍历累积状态的visitor对象,考虑经典访问者。
3.3 混合模式:现有继承体系里的variant适配
还有一种常见场景:你的代码已经有一棵继承树(比如AST节点都继承自Expr基类),但你嫌手写Visitor太啰嗦,想用std::visit。此时有个过渡方案:写一个统一的Accept入口,把所有子类转化成对应的variant索引,再让std::visit去调度。
cpp复制class Expr {
public:
virtual ~Expr() = default;
virtual std::variant<IntExpr*, AddExpr*, MulExpr*> toVariant() = 0;
};
class IntExpr : public Expr {
public:
std::variant<IntExpr*, AddExpr*, MulExpr*> toVariant() override {
return this;
}
};
这等于在继承树外面包了一层variant壳。优点是能用std::visit的简洁语法访问整个AST;缺点是需要给每个类写一个toVariant,新增子类时也得同步修改variant类型——又回到封闭集合的问题了。所以这个混合方案适合那种“AST类型稳定、只追求访问代码简洁”的项目,不适合类型频繁演进的场景。
4. 高级杂交技巧:acyclic visitor、CRTP、泛型访问
如果要用好经典访问者,有几项“高阶技术”可以显著提高代码质量和可维护性。这一节的材料来自我自己的项目实践和一些社区大牛的文章,每条都经过测试。
4.1 acyclic visitor:解决循环依赖的最终方案
前面提到经典访问者有循环依赖问题。acyclic visitor是解决这个问题的主要手段之一。它的核心思想:不再用一个统一的Visitor抽象类,而是给每个元素类型定义一个独立的Visitor接口(比如IntExprVisitor、AddExprVisitor),元素类的accept接受一个抽象基类指针(比如ExprVisitorBase),内部用dynamic_cast去查找自己对应的那个Visitor接口。
cpp复制class ExprVisitorBase {
public:
virtual ~ExprVisitorBase() = default;
};
class IntExprVisitor {
public:
virtual void visit(IntExpr& e) = 0;
};
class AddExprVisitor {
public:
virtual void visit(AddExpr& e) = 0;
};
class IntExpr : public Expr {
public:
void accept(ExprVisitorBase& v) override {
if (auto* iv = dynamic_cast<IntExprVisitor*>(&v)) {
iv->visit(*this);
}
}
};
好处很明显:访问者类不需要在定义时知道所有元素类型,它可以只实现它关心的接口,代码之间解耦。坏处也明显:dynamic_cast带来了运行时开销,而且如果漏实现某一个接口,accept会静默失败——不报错、不告警,运行起来直接什么都不做。这比经典方案的“漏实现会编译失败”要危险得多,所以在重用acyclic visitor时,最好在accept里加断言。
code复制如果你追求“接口可扩展、但必须安全”,可以考虑在accept里用dynamic_cast失败时打LOG并断言。线上环境关断言的前提下,再让人工检查补上。
4.2 用CRTP自动生成Accept样板
经典访问者里,每个元素类都要写一段几乎相同的accept,内容都是v.visit(*this)。类多了以后这段样板代码非常烦人,而且容易复制粘贴出错。此处可以借助CRTP自动生成。
cpp复制template <typename Derived>
class Visitable : public Element {
public:
void accept(Visitor& v) override {
v.visit(static_cast<Derived&>(*this));
}
};
class IntExpr : public Visitable<IntExpr> {
public:
int value() const { return 42; }
};
在accept里,static_cast<Derived&>(*this)拿到当前最派生类型,然后直接调用Visitor中对应的重载。编译器在模板实例化时就能确定哪个重载,所以不存在动态分派的额外开销。它比手写accept更不容易出错,而且后续加新元素时只需要继承Visitable<YourClass>就行。
这个CRTP模式在实现staging buffer的序列化框架时非常实用,因为节点类型很多,手写accept早就写疯了,用CRTP统一生成,代码量一下少很多。
4.3 泛型访问者:if constexpr与模板模板参数
如果你的访问者需要处理一批没有共同基类、但结构相似的类型,泛型访问者能进一步压缩重复代码。比如你想给一组表达式节点统一导出为字符串,但它们的处理逻辑只有小差异,就可以写一个泛型visitor,内部用if constexpr区分类型,而不是一个类型一个重载。
cpp复制class ExporterVisitor : public Visitor {
public:
template <typename T>
void visitGeneric(T& node) {
if constexpr (std::is_same_v<T, IntExpr>) {
result_ = std::to_string(node.value());
} else if constexpr (std::is_same_v<T, AddExpr>) {
node.left()->accept(*this);
std::string lhs = std::move(result_);
node.right()->accept(*this);
result_ = "(" + lhs + " + " + result_ + ")";
} else {
static_assert(!sizeof(T), "Unsupported node type");
}
}
};
这个手法的巧妙之处在于模板的“所有类型分支都在编译期解析”的特性。你不需要为每个类型手工写一个visit(IntExpr&),只需要让每个visit重载调用visitGeneric。配合上面的CRTP,一段处理几十种节点的泛型代码可以塞进一个模板里,可维护性高一个量级。
当然,过度使用if constexpr也可能让代码变成“披着模板外衣的手工分派”。所以我的判断标准是:只有当多个类型的处理逻辑高度相似时再合并,否则各写各的visit重载更直观。
4.4 编译期访问者
C++20的consteval/constexpr能力更进一步,你可以让Visitor在编译期就完成访问。这里一般配合std::variant使用,因为variant本身是值语义,可以在constexpr函数里被递归解析。
cpp复制constexpr int eval(std::variant<IntExpr, AddExpr> expr) {
return std::visit([](auto&& single) -> int {
using T = std::decay_t<decltype(single)>;
if constexpr (std::is_same_v<T, IntExpr>) {
return single.value;
} else if constexpr (std::is_same_v<T, AddExpr>) {
return eval(single.lhs) + eval(single.rhs);
}
}, expr);
}
这段代码能在编译期算出一个常量表达式。对解释器、编译器的常数折叠阶段,这非常有用。不过要注意:编译期复杂度过高会导致编译时间暴涨,实践中还是以运行期为主、编译期优化为辅助。
5. 实战选型:从AST遍历到消息分发、序列化框架
理论聊了不少,最后落到几个我实际项目中用过的场景,给每个场景一个比较明确的选型建议。
5.1 AST遍历:访问者还是std::visit?
编译器项目里AST遍历是访问者模式最经典的舞台。如果你的AST是继承树(为了和旧代码兼容或者表达“某些节点是另一些节点的具体情况”),经典Visitor仍然是首选,特别是当遍历逻辑会分多次累积状态时(符号表收集、类型检查、代码生成),一次遍历需要跨节点共享一个状态对象,成员变量天然合适。
如果你的AST是通过std::variant建模的(比如新项目,并且所有类型都在你控制之下),那std::visit更好,代码量少一大截,不会因为漏实现某个visit重载而编译失败。
我做的一个解释器项目就经历过经典Visitor到std::variant的迁移。原来AST有30多个节点类,手写了30多组visit重载;改成variant后,同一套逻辑代码量只有原来的六成,而且很多分支都能靠泛型lambda合并。唯一纠结的是,后来要加一个新节点类型时,需要改判variant类型定义和所有使用该variant的地方,这个耦合被做进了编译期检查里——报错会一次性列出所有需要更新的visit调用,不算是坏事。
5.2 消息分发:访问者作为协议转换器
消息系统里,一个协议包可能有几十种类型,需要对每种类型做不同的处理。最笨的办法是一串if-else + dynamic_cast,写起来又臭又长,每加一个类型改一堆地方。访问者在这里的价值是让分发逻辑变成一个独立的Handler类,未来的业务扩展可以通过实现新的Handler完成,而不用改动消息类本身的继承体系。
如果你用的是protobuf/gRPC那一套生成的C++代码,消息类没有共同基类但都生成出来了,此时用std::visit并不能直接套——因为proto生成的类是独立类,没有variant包装。这种情况我习惯自己维护一个“类型索引到消息子类的映射表”,再配合一个基类指针加上一次跳转表(或者干脆动态类型判断),本质上还是访问者模式的一种变形。
code复制实际做消息网关时,你会发现纯访问者模式没法覆盖所有需求——有些消息要按优先级处理,有些要按来源IP过滤。这时访问者只是分发核心,外围还需要策略模式或者责任链模式补充。模式之间要配合,别指望单兵突进。
5.3 序列化框架中的双重分派
序列化是访问者模式的另一个典型应用。JSON序列化时,每个对象要根据自己的实际类型选择序列化逻辑。使用访问者模式把SerializeVisitor做出来,每个对象的accept调用visitor.visit(*this),最终调用对应类型的序列化重载。
这个场景最大的优势是:序列化逻辑(比如JSON格式版本变化)是一个变化源,对象类型又是一个变化源,访问者把它们隔离开。你升级JSON格式时不会碰到业务对象定义的代码;你新增业务对象时只需让它支持accept,序列化框架无需改动。
但序列化常常和反射结合,C++的反射支持很弱,要实现通用的序列化框架还需要大量模板宏保底。访问者承担的更多是“把类型分发到操作方法”这一层,真正的字段遍历还得依赖每个类型自行迭代成员变量。
5.4 性能与可维护性权衡
用访问者模式跑分派,成本其实不低。经典继承实现中,一次accept至少两次虚函数调用,加上访问器对象构造,性能敏感的循环里会有明显影响。std::variant的分派是编译期跳转表,比虚函数快一些,但前提是访问逻辑本身不复杂。
实测下来,如果一个循环体里做大量字符串拼接或者分配内存,分派开销根本不在话下;但如果你在一个热路径上做百万次元素遍历,每次分派都绑着一大堆对象构造,性能差距就能拉开几个数量级。遇到这种场景可以考虑两个方向:一是把遍历循环直接改成if constexpr/模板展开,绕过分派;二是把访问器设计成无状态、可复用对象,避免每次重新构造。
这里也顺带说明一下和std::variant对比时的性能细节。std::visit通常被实现为索引查表,复杂度接近O(1),但表可能占一些缓存;经典虚函数调用是比较直接的间接跳转,对缓存友好。实际性能差异通常不明显,除非你的元素类型数量极大(超过几十个)并且分派极其频繁。这时候建议以profile结果为准,不要凭空猜。
6. 我看到的一些反模式与改进建议
技术细节讲完了,再花点篇幅聊聊访问者模式在实际应用里容易误用和滥用的地方。这些教训基本来自我踩过坑或者review别人代码时发现的问题。
6.1 访问者里塞了太多类型判断
访问者模式的卖点之一就是“不在被访问类里写if-else判断类型”。但有些人的Visitor实现里全是dynamic_cast或其他类型判断,这等于把分派逻辑又带回了访问者里,只不过位置换了一下。比如:
cpp复制void visit(Element& e) override {
if (auto* a = dynamic_cast<IntExpr*>(&e)) { ... }
else if (auto* b = dynamic_cast<AddExpr*>(&e)) { ... }
}
这是很明显的反模式。使用访问者就应该利用C++的静态重载解析,把每一种类型的处理写到单独的visit重载中。如果你发现大量Visitor实现都在做类型判断,很有可能是你的元素继承体系设计得不够合理,或者你根本不应该用访问者模式。
6.2 访问者处理逻辑太重,类越来越胖
访问者模式的初衷是让操作集中在Visitor类里,但如果某个Visitor实现了大量复杂的业务逻辑,这个类就会变成一个“上帝对象”,功能一多就失控。我的经验是:一份Visitor最好只做一件事。比如AST遍历可以做SemanticCheckVisitor、CodegenVisitor、ConstantFoldVisitor,别揉到一个SuperVisitor里。
同时注意Visitor的扩展点。如果你新增了一个操作,那么可以在新Visitor中复用老Visitor的逻辑(通过组合或继承),但避免让新Visitor继承老Visitor后大幅度覆盖行为——继承层次太多,阅读和维护成本会指数增长。
6.3 忘记处理const正确性
前面提过const版本和非const版本的问题。很多项目只写了一个visit(Element&)非const版本,然后所有接受只读分析的Visitor都用同一个接口,内部对元素做const_cast,糊弄过去。这在小型项目里问题不大,但大型项目里会埋下不少隐患——一个modifier型Visitor和一个analyzer型Visitor共用同一份接口,漏掉const修饰的成员函数就会悄悄改写数据,非常难查。
如果你发现自己的代码里大量出现const_cast处理访问者,建议尽早拆开两个Visitor接口:一个给只读分析用,一个给修改用。虽然多写几个重载,但编译期保障远比后期埋雷强。
6.4 和新语言特性结合时的“简单优先”原则
最后给一个普适的建议:如果你在犹豫到底用经典访问者还是std::visit,先看你的项目里有没有现成的继承体系。如果有一个现成的、稳定的继承树,而且改动成本很高,那就直接在它上面加Visitor,不要硬换成variant;如果你从零开始写小型工具,元素类型又控制在某个封闭集合里,std::visit简洁太多,优先选它。
我自己实际写了几年之后,最大的体会是访问者模式的价值不在“模式”本身,而在“把类型分派和数据操作分开”的思想。经典实现也好,std::visit也好,只要是围绕这个思想设计的,都能写出来可维护性很高的代码。反之,如果你只是把它当成八股面试题背,写几百行代码套进去却不理解为什么这么设计,那这个模式对你就是负担,而不是工具。
写到这里,我想到一个真实的坑,以前有一次为了复用某个访问器状态,在多层嵌套的递归遍历里复用了同一个Visitor实例,结果状态被下层遍历冲掉了,查了一个下午才定位到问题。后来我把累积状态拆成显式的上下文对象,传给每个visit调用,就再没遇到过这种诡异的脏数据。类似的坑很多,但核心原则都一样:访问者的状态管理一定要清晰、显式,别靠隐式的成员变量反复横跳。希望这篇东西能帮你少走这些弯路。
