1. 项目概述为什么我们需要访问者模式干了这么多年C我发现很多同行在设计模式里对访问者模式Visitor的态度很微妙要么觉得它太复杂、用不上要么就是面试前突击背一下“双分派”的概念实际项目里能躲就躲。但说实话在我处理过的一些复杂系统尤其是编译器前端、文档对象模型DOM处理、或者需要频繁添加新操作但又不愿动老代码的场景里访问者模式的价值就凸显出来了。它本质上解决的是一种“结构稳定但操作多变”的困境。想象一下你手里有一个已经设计好的类层次结构比如抽象语法树AST里面有各种节点类型表达式节点、语句节点、声明节点等等。这些节点的结构即类之间的关系在项目初期就定好了并且相当稳定。但你的需求却在不断变化今天要写一个语法高亮器明天要加一个代码格式化工具后天又要实现一个静态分析器来检查未使用的变量。每一个新需求都对应着一套要遍历整个AST并针对不同节点执行不同操作的逻辑。如果按照最直观的做法你会怎么做很可能就是在每个节点类里不断添加新的虚函数比如Highlight()、Format()、CheckUnusedVariables()。这带来的问题显而易见每加一个新功能你都得打开每一个节点类的头文件和源文件进行修改这违反了开闭原则对扩展开放对修改关闭也让核心的数据类变得越来越臃肿充满了各种不相关的职责。访问者模式就是为了把“数据结构”和“作用于数据结构上的操作”解耦而生的。它把那些变化频繁的操作访问者从稳定的数据结构元素中抽离出来。这样当需要增加新的操作时你只需要新增一个访问者类而不需要去碰任何一个已有的元素类。这对于维护大型、历史悠久的代码库来说简直就是救星。当然它的引入也增加了系统的复杂性理解“接受-访问”的双分派机制是掌握它的关键。接下来我们就抛开那些枯燥的理论直接进入实战看看在C里怎么把它用对、用好。2. 访问者模式的核心机制与双分派解析要玩转访问者模式必须吃透它的两个核心机制元素Element的Accept方法和访问者Visitor的Visit方法是如何协作完成“双分派”的。这是理解其威力和复杂度的钥匙。2.1 类图结构与角色职责我们先在脑子里建立一个清晰的类图。访问者模式主要包含两个继承层次访问者层次Visitor Hierarchy 定义一个访问者接口Visitor其中为每一个具体的元素类ConcreteElementA,ConcreteElementB...声明一个对应的访问方法VisitConcreteElementA,VisitConcreteElementB。然后针对不同的操作实现不同的具体访问者ConcreteVisitor1,ConcreteVisitor2。元素层次Element Hierarchy 定义一个元素接口Element其中声明一个Accept(Visitor*)方法。每一个具体的元素类实现这个Accept方法在方法内部调用传入的访问者对象的、对应于自己类型的Visit方法。这个结构的关键在于元素是“被访问”的被动方而访问者是携带“操作逻辑”的主动方。但触发操作的调用链路是从客户端调用元素的Accept开始的。2.2 深入双分派为何C需要“Accept/Visit”这套机制“双分派”这个词听起来高大上其实就是为了解决一个C以及大多数单分派语言的天然限制基于运行时类型的函数重载动态多态只能发生一次。让我用一个反例来解释。假设我们不用访问者模式而是试图在访问者里用重载函数来处理不同元素class NaiveVisitor { public: virtual void Visit(ConcreteElementA* elem) { /* 处理A */ } virtual void Visit(ConcreteElementB* elem) { /* 处理B */ } }; // 客户端代码 Element* elem new ConcreteElementA(); NaiveVisitor* visitor new ConcreteVisitor1(); visitor-Visit(elem); // 问题来了在visitor-Visit(elem)这一行编译器在编译时只知道elem是Element*类型。虽然它实际指向一个ConcreteElementA对象但函数重载决议发生在编译时此时只能看到Visit(Element*)版本如果我们有的话或者更糟找不到匹配的函数而报错。也就是说我们无法同时根据visitor和elem两者的实际运行时类型来选择正确的函数。访问者模式通过两次虚函数调用即两次动态分派巧妙地绕过了这个限制第一次分派 客户端调用element-Accept(visitor)。由于element的静态类型是Element*但实际类型是ConcreteElementA*所以这次虚调用会动态分派到ConcreteElementA::Accept。第二次分派 在ConcreteElementA::Accept(Visitor* visitor)方法内部它调用visitor-VisitConcreteComponentA(this)。注意此时this的静态类型是ConcreteElementA*是明确的因此这次调用虽然visitor是基类指针但VisitConcreteComponentA这个函数名已经确定了要调用访问者类中处理A类型的方法。接下来由于visitor的实际类型可能是ConcreteVisitor1或ConcreteVisitor2所以这次虚调用会再次发生动态分派最终执行到ConcreteVisitor1::VisitConcreteComponentA或ConcreteVisitor2::VisitConcreteComponentA。这样通过Accept和Visit的接力我们最终根据元素的实际类型和访问者的实际类型精准地调用了正确的处理函数。这就是“双分派”。实操心得很多初学者会纠结为什么非要元素提供一个Accept方法觉得让客户端直接调用visitor-Visit(element)不行吗通过上面的分析你就明白了不行因为那样无法解决第一次分派根据element类型选择不同Visit函数的问题。Accept方法是实现双分派的必要桥梁是元素主动“接待”访问者的入口。3. 实战详解从零构建一个迷你抽象语法树AST处理器理论讲得再多不如一行代码。我们用一个更贴近实战的例子来彻底掌握它实现一个简单的算术表达式求值器和格式化打印器。我们的“数据结构”是一个迷你AST包含常数、加法和乘法表达式。3.1 定义元素层次表达式节点首先我们定义所有表达式节点的基类Expr。它必须提供一个纯虚的Accept方法接受一个访问者基类的引用。// visitor.h #ifndef VISITOR_H #define VISITOR_H // 前向声明解决循环依赖 class NumberExpr; class AddExpr; class MultiplyExpr; // 访问者基类接口 class ExprVisitor { public: virtual ~ExprVisitor() default; virtual void VisitNumber(const NumberExpr* expr) 0; virtual void VisitAdd(const AddExpr* expr) 0; virtual void VisitMultiply(const MultiplyExpr* expr) 0; }; // 表达式元素基类 class Expr { public: virtual ~Expr() default; virtual void Accept(ExprVisitor visitor) const 0; // 注意是const方法表示访问不修改元素 }; #endif // VISITOR_H注意这里的前向声明和Visit方法的参数类型。将Visit方法声明为接受const指针并让Accept是const方法是一个好习惯它明确告知访问操作通常不应修改被访问的元素结构本身这符合访问者模式用于“执行操作”而非“修改结构”的常见场景。接下来实现三个具体的表达式节点。// expr.h #ifndef EXPR_H #define EXPR_H #include visitor.h #include memory // 常数表达式节点 class NumberExpr : public Expr { public: explicit NumberExpr(int value) : value_(value) {} int GetValue() const { return value_; } void Accept(ExprVisitor visitor) const override { visitor.VisitNumber(this); // 关键调用处理Number类型的Visit方法 } private: int value_; }; // 加法表达式节点 class AddExpr : public Expr { public: AddExpr(std::unique_ptrExpr left, std::unique_ptrExpr right) : left_(std::move(left)), right_(std::move(right)) {} const Expr* GetLeft() const { return left_.get(); } const Expr* GetRight() const { return right_.get(); } void Accept(ExprVisitor visitor) const override { visitor.VisitAdd(this); // 关键调用处理Add类型的Visit方法 } private: std::unique_ptrExpr left_; std::unique_ptrExpr right_; }; // 乘法表达式节点 class MultiplyExpr : public Expr { public: MultiplyExpr(std::unique_ptrExpr left, std::unique_ptrExpr right) : left_(std::move(left)), right_(std::move(right)) {} const Expr* GetLeft() const { return left_.get(); } const Expr* GetRight() const { return right_.get(); } void Accept(ExprVisitor visitor) const override { visitor.VisitMultiply(this); // 关键调用处理Multiply类型的Visit方法 } private: std::unique_ptrExpr left_; std::unique_ptrExpr right_; }; #endif // EXPR_H每个具体节点的Accept实现都非常规整就是调用访问者接口中与自己类型对应的那个Visit方法并把this指针传过去。这就是双分派中的“第二次分派”的发起者。3.2 实现具体访问者求值器与格式化器现在我们来创建两个访问者代表两种不同的操作。第一个访问者求值器 (Evaluator)。它的任务是遍历AST计算出表达式的整数值。// evaluator.h #ifndef EVALUATOR_H #define EVALUATOR_H #include visitor.h class Evaluator : public ExprVisitor { public: int GetResult() const { return result_; } void VisitNumber(const NumberExpr* expr) override { result_ expr-GetValue(); // 遇到常数结果就是常数值 } void VisitAdd(const AddExpr* expr) override { // 递归计算左子树 expr-GetLeft()-Accept(*this); int left_result result_; // 递归计算右子树 expr-GetRight()-Accept(*this); int right_result result_; // 合并结果 result_ left_result right_result; } void VisitMultiply(const MultiplyExpr* expr) override { expr-GetLeft()-Accept(*this); int left_result result_; expr-GetRight()-Accept(*this); int right_result result_; result_ left_result * right_result; } private: int result_ 0; };注意VisitAdd和VisitMultiply的实现。为了计算子表达式访问者需要“反身”去访问子节点。这里通过expr-GetLeft()-Accept(*this)实现。*this就是求值器对象本身它作为访问者被传递给了子节点。这个过程是递归的完美地遍历了整个树形结构。注意事项这里result_作为一个成员变量在递归访问中被反复覆写。对于简单的求值器没问题但如果访问者需要更复杂的状态管理比如为每个节点生成独立的结果就需要更精细的设计例如让Visit方法返回值或者使用一个栈来保存中间状态。第二个访问者格式化器 (Printer)。它的任务是将AST转换成可读的字符串比如中缀表达式。// printer.h #ifndef PRINTER_H #define PRINTER_H #include visitor.h #include string #include sstream class Printer : public ExprVisitor { public: const std::string GetString() const { return oss_.str(); } void Clear() { oss_.str(); oss_.clear(); } void VisitNumber(const NumberExpr* expr) override { oss_ expr-GetValue(); } void VisitAdd(const AddExpr* expr) override { oss_ (; expr-GetLeft()-Accept(*this); oss_ ; expr-GetRight()-Accept(*this); oss_ ); } void VisitMultiply(const MultiplyExpr* expr) override { expr-GetLeft()-Accept(*this); oss_ * ; expr-GetRight()-Accept(*this); } private: std::ostringstream oss_; };格式化器的逻辑更直观就是在访问节点时向一个字符串流中拼接括号和运算符。同样它也需要递归访问子节点来构建完整的字符串。3.3 客户端代码与运行演示最后我们编写客户端代码来组装一个表达式树并分别用求值器和格式化器来处理它。// main.cpp #include iostream #include memory #include expr.h #include evaluator.h #include printer.h int main() { // 构建表达式树(2 3) * 4 // 等价于new MultiplyExpr(new AddExpr(new NumberExpr(2), new NumberExpr(3)), new NumberExpr(4)) auto expr std::make_uniqueMultiplyExpr( std::make_uniqueAddExpr( std::make_uniqueNumberExpr(2), std::make_uniqueNumberExpr(3) ), std::make_uniqueNumberExpr(4) ); // 使用求值器访问 Evaluator eval; expr-Accept(eval); std::cout Evaluation result: eval.GetResult() std::endl; // 输出: 20 // 使用格式化器访问 Printer printer; expr-Accept(printer); std::cout Formatted expression: printer.GetString() std::endl; // 输出: (2 3) * 4 // 试试另一个表达式2 3 * 4 auto expr2 std::make_uniqueAddExpr( std::make_uniqueNumberExpr(2), std::make_uniqueMultiplyExpr( std::make_uniqueNumberExpr(3), std::make_uniqueNumberExpr(4) ) ); printer.Clear(); expr2-Accept(printer); std::cout Formatted expression2: printer.GetString() std::endl; // 输出: (2 3 * 4) 注意我们的Printer没有智能处理优先级所以是(2 (3 * 4))的简化版 eval Evaluator(); // 重置求值器 expr2-Accept(eval); std::cout Evaluation result2: eval.GetResult() std::endl; // 输出: 14 return 0; }编译并运行这段代码你会看到两个访问者各自独立地工作对同一个数据结构执行完全不同的操作而Expr及其子类没有为“求值”或“打印”做过任何修改。这就是访问者模式的核心威力操作与结构的解耦。4. 访问者模式的进阶应用与设计权衡掌握了基础实现后我们需要深入讨论一些实际工程中会遇到的问题和更高级的用法。访问者模式并非银弹理解其适用场景和缺陷同样重要。4.1 处理异构元素集合与迭代遍历在实际项目中你很少只访问单个对象更多的是遍历一个复杂的对象结构比如一个包含所有AST节点的列表、一个组合模式Composite构建的树。访问者模式与迭代器模式或组合模式结合使用非常普遍。假设我们有一个Program类它包含多个表达式语句。我们可以让Program也成为一个元素实现Accept在其Accept方法中遍历所有子表达式并调用它们的Accept。class Program : public Expr { // 假设Program也是一种特殊的Expr public: void AddStatement(std::unique_ptrExpr stmt) { statements_.push_back(std::move(stmt)); } void Accept(ExprVisitor visitor) const override { // 可以先对Program本身做一些操作如果有对应的VisitProgram方法 // visitor.VisitProgram(this); // 然后遍历所有语句 for (const auto stmt : statements_) { stmt-Accept(visitor); } } private: std::vectorstd::unique_ptrExpr statements_; };这样客户端只需要对Program根节点调用一次Accept访问者就能遍历整个程序的所有语句。访问者模式非常适合这种“遍历分发”的场景。4.2 访问者模式的优缺点与适用场景分析经过上面的实战我们可以系统地总结一下访问者模式的利弊。优点开闭原则 这是最大的优点。你可以新增访问者新操作而无需修改现有的元素类。这对于需要频繁添加新功能如新的代码分析、新的导出格式的框架或库来说至关重要。单一职责原则 将相关的行为集中在一个访问者类中而不是分散在各个元素类里。例如所有关于“求值”的逻辑都在Evaluator里所有关于“打印”的逻辑都在Printer里。状态累积 访问者对象可以在遍历过程中方便地累积状态。比如上面的Evaluator用result_成员变量保存结果Printer用字符串流构建输出。你也可以实现一个收集所有变量名的访问者。缺点破坏封装 这是访问者模式最受诟病的一点。为了让访问者能对元素执行操作元素往往必须提供一些公有接口如GetLeft(),GetValue()来暴露其内部状态。这可能会破坏元素的封装性。在某些设计中可以通过让元素类将访问者声明为友元friend来缓解但这又带来了紧耦合。元素层次结构必须稳定 访问者模式要求元素类的层次结构即有哪些具体的Element子类是基本稳定的。因为每增加一个新的具体元素类你都需要在Visitor基类接口中增加一个新的纯虚Visit方法这会导致所有已有的具体访问者类都需要跟着修改增加一个空实现或默认实现。如果元素类频繁增加维护成本会急剧上升。依赖具体类 访问者接口的方法签名直接依赖于所有具体元素类这违反了“依赖倒置原则”应依赖抽象而非具体。访问者模式实际上是在知道所有具体元素类型的前提下工作的。可能阻碍元素演化 如果元素需要改变其内部结构可能会影响到所有依赖该结构的访问者。适用场景对象结构稳定但操作频繁变化或扩展。这是使用访问者模式的首要条件。编译器、解释器、文档处理器是经典用例。需要对一个复杂对象结构如树、图中的各元素执行许多不同且不相关的操作并且你希望避免这些操作“污染”元素类。操作需要跨多个类聚合结果。访问者可以很方便地在遍历过程中收集信息。实操心得在决定使用访问者模式前一定要问自己两个问题1. 我的元素类层次结构在未来半年到一年内还会频繁增加新类型吗2. 我预期会有很多种完全不同的、需要遍历整个结构的操作吗如果两个答案都是“是”那么访问者模式是一个强有力的候选。如果第一个答案是“否”第二个是“是”也可以考虑。如果两个都是“否”那可能用简单的虚函数或策略模式就够了别过度设计。5. 常见问题、陷阱与性能优化实录在实际项目中应用访问者模式你会遇到一些教科书里不会细讲的坑。这里我结合自己的经验分享几个典型问题和解决思路。5.1 循环依赖与头文件组织你肯定注意到了在定义Visitor基类时我们需要前向声明所有具体的元素类class NumberExpr;。这是因为Visitor的接口方法需要这些具体类作为参数。同时具体的元素类如NumberExpr又需要#include visitor.h来获取ExprVisitor的定义。这就形成了一个头文件循环依赖的潜在风险。我们的解决方法是将接口声明与实现分离 正如我们示例中所做Visitor基类只包含纯虚函数声明放在visitor.h。具体的访问者实现放在独立的evaluator.h/cppprinter.h/cpp中。使用前向声明 在visitor.h中只对具体元素类做前向声明而不包含它们的头文件。因为这里只用到指针或引用编译器只需要知道这些类存在即可。在具体访问者的实现文件中包含具体元素类的头文件 例如在evaluator.cpp中需要#include expr.h因为要调用expr-GetValue()等方法。这种组织方式清晰且避免了编译依赖的纠缠。5.2 处理新增元素类型Visitor的演化假设我们的AST需要新增一个SubtractExpr减法表达式节点。这会对现有代码造成什么影响修改Visitor基类接口 必须在ExprVisitor中增加一个新的纯虚函数virtual void VisitSubtract(const SubtractExpr* expr) 0;。所有现有具体访问者类编译失败 因为它们是ExprVisitor的子类没有实现新的纯虚函数。你必须打开每一个访问者类Evaluator,Printer等为它们添加VisitSubtract的实现哪怕只是一个空实现或抛出一个“未实现”异常。这就是访问者模式最大的维护痛点。为了缓解这个问题可以考虑以下策略提供一个带默认实现的Visitor适配器 创建一个中间类ExprVisitorAdapter继承ExprVisitor并为所有Visit方法提供空的或抛出异常的默认实现。然后让你的具体访问者继承这个适配器而不是直接继承ExprVisitor。这样当新增元素类型时你只需要修改适配器类现有的具体访问者类除非需要处理新类型否则可以暂时不修改但可能需要重新编译。class ExprVisitorAdapter : public ExprVisitor { public: void VisitNumber(const NumberExpr*) override { /* 默认空实现 */ } void VisitAdd(const AddExpr*) override { /* 默认空实现 */ } void VisitMultiply(const MultiplyExpr*) override { /* 默认空实现 */ } void VisitSubtract(const SubtractExpr*) override { /* 新增类型的默认空实现 */ } }; // Evaluator 改为继承自 ExprVisitorAdapter class Evaluator : public ExprVisitorAdapter { ... };使用dynamic_cast或类型枚举作为备选不推荐 如果元素类型真的无法稳定或许访问者模式本身就不太适合。强行使用可能会导致代码充满类型检查和转换失去模式本身的优雅。5.3 性能考量虚函数开销与缓存访问者模式涉及大量的虚函数调用每个节点的Accept和对应的Visit。在性能极其敏感的路径如高频执行的代码分析循环中这可能会成为瓶颈。虚函数开销 每次虚函数调用都有一次间接寻址通过虚函数表vtable。对于深度很大或节点数量极多的树累积的开销可能可观。但在绝大多数应用场景下这部分开销与I/O、算法复杂度相比微不足道不应成为拒绝使用访问者模式的首要理由。缓存优化 如果某个访问者的操作非常昂贵并且元素结构不常变化可以考虑在访问者内部实现缓存。例如一个计算表达式值的访问者可以为每个表达式节点缓存计算结果避免对同一子树重复计算。这需要访问者能唯一标识节点比如通过节点地址或哈希值并处理好节点生命周期带来的缓存失效问题。5.4 访问者模式与其他模式的结合访问者模式很少单独使用它常与其他模式协同工作与组合模式Composite 这是黄金搭档。组合模式帮你构建树形结构访问者模式帮你遍历树并对每个节点执行操作。我们的AST例子就是这种组合的体现。与迭代器模式Iterator 迭代器负责以某种顺序遍历集合而访问者负责对遍历到的每个元素执行操作。你可以让迭代器的next()操作返回元素并调用其Accept。与解释器模式Interpreter 在解释器模式中抽象语法树是解释器的核心数据结构访问者模式是实现语法树求值、格式化等操作的典型手段。6. 现代C下的实现技巧与变体C11/14/17标准引入的新特性可以让访问者模式的实现更安全、更简洁。6.1 使用std::variant和std::visitC17如果你的元素类型集合是封闭的即所有类型在编译时已知并且你不想维护一个显式的Visitor类层次C17的std::variant和std::visit提供了一种非常优雅的替代方案这被称为“多方法”或“静态访问者”。#include variant #include iostream // 定义元素类型不再需要基类和虚函数 struct NumberExpr { int value; }; struct AddExpr { std::unique_ptrExpr left, right; }; // 注意Expr需要定义这里用variant本身 struct MultiplyExpr { std::unique_ptrExpr left, right; }; // 使用 variant 定义表达式类型 using Expr std::variantNumberExpr, AddExpr, MultiplyExpr; // 访问者现在是一组可调用对象函数对象或lambda的集合 struct Evaluator { int result_; void operator()(const NumberExpr e) { result_ e.value; } void operator()(const AddExpr e) { std::visit(*this, *e.left); // 递归访问 int left result_; std::visit(*this, *e.right); result_ left result_; } void operator()(const MultiplyExpr e) { std::visit(*this, *e.left); int left result_; std::visit(*this, *e.right); result_ left * result_; } }; int main() { Expr expr AddExpr{ std::make_uniqueExpr(NumberExpr{2}), std::make_uniqueExpr(MultiplyExpr{ std::make_uniqueExpr(NumberExpr{3}), std::make_uniqueExpr(NumberExpr{4}) }) }; Evaluator eval; std::visit(eval, expr); // 关键std::visit 自动根据expr的实际类型调用Evaluator对应的operator() std::cout eval.result_ std::endl; // 输出 14 }这种方式完全避免了定义Visitor接口和修改元素类的需要类型安全并且编译器可能生成更高效的代码因为分派逻辑可能在编译时优化。缺点是它要求所有类型在编译时已知并且std::visit需要访问者对所有可能的variant类型都重载了operator()否则编译会报错。6.2 使用dynamic_cast实现“应急”访问者Acyclic Visitor这是一种变体旨在解决经典访问者模式中Visitor接口依赖所有具体元素类的问题。它引入了一个更抽象的Visitor基类然后为每一类元素提供一个单独的Visitor子接口。class ExprVisitorBase { // 空基类只用于标识 public: virtual ~ExprVisitorBase() default; }; template typename T class ExprVisitor : public virtual ExprVisitorBase { // 虚继承 public: virtual void Visit(const T) 0; }; class Expr { public: virtual ~Expr() default; virtual void Accept(ExprVisitorBase) 0; // 接受基类 }; class NumberExpr : public Expr { public: void Accept(ExprVisitorBase base) override { if (auto* v dynamic_castExprVisitorNumberExpr*(base)) { v-Visit(*this); } // 如果转换失败说明这个访问者不关心NumberExpr什么也不做 } int value; };这样新增一种元素类型如SubtractExpr时只需要新增一个ExprVisitorSubtractExpr接口而不会影响已有的、不关心减法表达式的访问者。但它的代价是使用了运行时的dynamic_cast有一定性能开销并且实现起来更复杂。除非你的系统元素类型确实不稳定且差异很大否则经典访问者模式通常更简单直接。访问者模式是一个强大的工具但也是一把双刃剑。它通过引入额外的间接层和复杂性换来了操作与结构的极致解耦。在那些数据结构稳定而操作多变的领域比如编译器构建、UI框架、游戏引擎的场景图遍历中它几乎是不二之选。但在需求快速迭代、数据结构本身也频繁变化的初创项目里引入它可能为时过早。理解其原理看清其代价才能在合适的时机做出最恰当的设计决策。