19.C++设计模式-访问者模式

导语:在设计模式的殿堂中,访问者模式(Visitor Pattern)常被视为“最难理解”的模式之一。它涉及双重分发、打破封装、控制反转等反直觉概念。但一旦掌握,它在编译器、文档处理、序列化等场景中展现出的优雅与强大,是其他模式难以企及的。本文将从独立的C++代码实战出发,再深入剖析它与组合模式的黄金搭档用法,最终升华到五层思维模型,帮你彻底吃透访问者模式的“形”与“神”。


一、什么是访问者模式?

1.1 核心定义

表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素的类的前提下,定义作用于这些元素的新操作。

一句话总结:数据结构是稳定的,但操作是多变的。 访问者模式将“数据”与“算法”解耦,让你可以随意增加新的算法,而不需要修改数据类的代码。

1.2 解决的核心痛点

在经典面向对象设计中,我们被教导“数据和操作应该绑定在一起”。但如果你的系统中:

  • 对象结构包含许多不同的类(如AST节点有几十种)。
  • 你需要对这些对象执行多种完全不同的操作(如类型检查、代码生成、格式化打印)。
  • 你不希望每增加一个操作就去修改这几十个类的源码。

这时,访问者模式就是最佳解决方案。它本质上是一种横切关注点(Cross-Cutting Concern) 的手动实现,将思维从“这个对象能做什么”转向“这个系统需要对这批数据做什么”。

1.3 双重分发:模式的灵魂

C++ 是单分派语言——虚函数调用只看调用者的动态类型,不看参数的动态类型。访问者模式通过 accept 方法巧妙地模拟了双分派:

ConcreteVisitor1 ConcreteElementA 客户端 ConcreteVisitor1 ConcreteElementA 客户端 第一次分发: 根据对象的实际类型 选择Accept方法 第二次分发: 根据Visitor的实际类型 + 参数的静态类型 选择具体的Visit重载 Accept(visitor) VisitConcreteElementA(this) 返回结果
  1. 第一次分发element->accept(visitor) 利用虚函数多态,确定了当前元素的具体类型。
  2. 第二次分发:在 accept 内部调用 visitor->visit(this)。此时 this 的静态类型已经是具体子类指针,因此编译器会精确匹配到对应的重载版本。

accept 方法看似多余,实则是一个类型恢复机制——把“我是谁”这个运行时信息,通过虚函数表转化为编译期的静态类型信息,用一次额外的间接调用换取了完整的类型安全分发能力。


二、独立实战:图形渲染引擎示例

在进入复杂的树形结构之前,我们先看一个扁平结构的纯粹示例,理解访问者模式最基础的运作方式。

以一个图形渲染引擎为例:我们有不同的图形元素,需要支持“渲染”和“计算面积”两种操作,且未来可能轻松添加“序列化”、“碰撞检测”等新操作。

2.1 完整 C++ 代码

#include <iostream>
#include <string>
#include <cmath>
#include <memory>
#include <vector>

// ==================== 前向声明 ====================
class Circle;
class Rectangle;
class Triangle;

// ==================== 访问者接口 ====================
class ShapeVisitor {
public:
    virtual ~ShapeVisitor() = default;
    virtual void visit(Circle& circle) = 0;
    virtual void visit(Rectangle& rect) = 0;
    virtual void visit(Triangle& tri) = 0;
};

// ==================== 元素接口 ====================
class Shape {
public:
    virtual ~Shape() = default;
    virtual void accept(ShapeVisitor& visitor) = 0;
};

// ==================== 具体元素 ====================
class Circle : public Shape {
private:
    double radius_;
public:
    explicit Circle(double r) : radius_(r) {}
    double getRadius() const { return radius_; }
    
    void accept(ShapeVisitor& visitor) override {
        visitor.visit(*this); // this类型为Circle&,触发精确重载匹配
    }
};

class Rectangle : public Shape {
private:
    double width_, height_;
public:
    Rectangle(double w, double h) : width_(w), height_(h) {}
    double getWidth() const { return width_; }
    double getHeight() const { return height_; }
    
    void accept(ShapeVisitor& visitor) override {
        visitor.visit(*this);
    }
};

class Triangle : public Shape {
private:
    double a_, b_, c_;
public:
    Triangle(double a, double b, double c) : a_(a), b_(b), c_(c) {}
    double getA() const { return a_; }
    double getB() const { return b_; }
    double getC() const { return c_; }
    
    void accept(ShapeVisitor& visitor) override {
        visitor.visit(*this);
    }
};

// ==================== 具体访问者1:渲染 ====================
class RenderVisitor : public ShapeVisitor {
public:
    void visit(Circle& circle) override {
        std::cout << "[Render] Drawing Circle with radius=" 
                  << circle.getRadius() << "\n";
    }
    void visit(Rectangle& rect) override {
        std::cout << "[Render] Drawing Rectangle " 
                  << rect.getWidth() << "x" << rect.getHeight() << "\n";
    }
    void visit(Triangle& tri) override {
        std::cout << "[Render] Drawing Triangle (" 
                  << tri.getA() << "," << tri.getB() << "," << tri.getC() << ")\n";
    }
};

// ==================== 具体访问者2:面积计算 ====================
class AreaCalculatorVisitor : public ShapeVisitor {
private:
    double totalArea_ = 0.0;
public:
    void visit(Circle& circle) override {
        double area = M_PI * circle.getRadius() * circle.getRadius();
        totalArea_ += area;
        std::cout << "[Area] Circle area = " << area << "\n";
    }
    void visit(Rectangle& rect) override {
        double area = rect.getWidth() * rect.getHeight();
        totalArea_ += area;
        std::cout << "[Area] Rectangle area = " << area << "\n";
    }
    void visit(Triangle& tri) override {
        double s = (tri.getA() + tri.getB() + tri.getC()) / 2.0;
        double area = std::sqrt(s * (s-tri.getA()) * (s-tri.getB()) * (s-tri.getC()));
        totalArea_ += area;
        std::cout << "[Area] Triangle area = " << area << "\n";
    }
    
    double getTotalArea() const { return totalArea_; }
};

// ==================== 客户端使用 ====================
int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>(5.0));
    shapes.push_back(std::make_unique<Rectangle>(4.0, 6.0));
    shapes.push_back(std::make_unique<Triangle>(3.0, 4.0, 5.0));
    
    // 操作1:渲染
    std::cout << "=== Rendering ===\n";
    RenderVisitor renderer;
    for (auto& shape : shapes) {
        shape->accept(renderer);
    }
    
    // 操作2:计算面积
    std::cout << "\n=== Calculating Area ===\n";
    AreaCalculatorVisitor calculator;
    for (auto& shape : shapes) {
        shape->accept(calculator);
    }
    std::cout << "Total Area = " << calculator.getTotalArea() << "\n";
    
    return 0;
}

2.2 运行输出

=== Rendering ===
[Render] Drawing Circle with radius=5
[Render] Drawing Rectangle 4x6
[Render] Drawing Triangle (3,4,5)

=== Calculating Area ===
[Area] Circle area = 78.5398
[Area] Rectangle area = 24
[Area] Triangle area = 6
Total Area = 108.54

这个示例展示了访问者模式最纯粹的形态:扁平集合 + 多态分发。新增操作只需添加一个新的 Visitor 类,所有 Shape 子类无需任何改动。


三、黄金搭档:访问者模式 × 组合模式实战

当数据结构不再是扁平集合,而是树形结构时,访问者模式与组合模式的结合便成为了“黄金搭档”。组合模式解决了“如何统一表示树形结构”,访问者模式解决了“如何在不修改树节点类的前提下对整棵树执行多种操作”。

下面以一个 “文件系统分析器” 为例,展示两者如何完美配合。

3.1 架构设计

accepts

accepts

contains children

«interface»

FileSystemElement

+accept(FileSystemVisitor& v)

+getName() : string

File

-size_: size_t

+accept(FileSystemVisitor& v)

+getSize() : size_t

Directory

-children_: vector<unique_ptr<FileSystemElement>>

+accept(FileSystemVisitor& v)

+add(unique_ptr<FileSystemElement> child)

+getChildren() : vector&

«interface»

FileSystemVisitor

+visitFile(File& f)

+visitDirectory(Directory& d)

SizeCalculatorVisitor

-totalSize_: size_t

+visitFile(File& f)

+visitDirectory(Directory& d)

+printReport()

TreePrinterVisitor

-depth_: int

+visitFile(File& f)

+visitDirectory(Directory& d)

💡 核心协作点:遍历逻辑与操作逻辑完全解耦。Directory::accept() 仅负责将自身交给访问者,不包含递归;递归控制权完全交给需要它的 Visitor,这使得状态管理(如缩进层级)可以安全地跟随调用栈自然增减。

3.2 完整 C++ 实现

#include <iostream>
#include <string>
#include <vector>
#include <memory>

// ==================== 前向声明 ====================
class File;
class Directory;

// ==================== 访问者接口 ====================
class FileSystemVisitor {
public:
    virtual ~FileSystemVisitor() = default;
    virtual void visitFile(File& file) = 0;
    virtual void visitDirectory(Directory& dir) = 0;
};

// ==================== 组合模式:元素接口 ====================
class FileSystemElement {
public:
    virtual ~FileSystemElement() = default;
    virtual void accept(FileSystemVisitor& visitor) = 0;
    virtual std::string getName() const = 0;
};

// ==================== 叶子节点:文件 ====================
class File : public FileSystemElement {
private:
    std::string name_;
    size_t size_;
public:
    File(std::string name, size_t size) 
        : name_(std::move(name)), size_(size) {}
    
    std::string getName() const override { return name_; }
    size_t getSize() const { return size_; }
    
    void accept(FileSystemVisitor& visitor) override {
        visitor.visitFile(*this);
    }
};

// ==================== 容器节点:目录 ====================
class Directory : public FileSystemElement {
private:
    std::string name_;
    std::vector<std::unique_ptr<FileSystemElement>> children_;
public:
    explicit Directory(std::string name) : name_(std::move(name)) {}
    
    std::string getName() const override { return name_; }
    
    void add(std::unique_ptr<FileSystemElement> child) {
        children_.push_back(std::move(child));
    }
    
    const std::vector<std::unique_ptr<FileSystemElement>>& getChildren() const {
        return children_;
    }
    
    // ⭐ 仅提供基础accept,不在此处做递归遍历
    // 递归控制权完全交给需要它的Visitor
    void accept(FileSystemVisitor& visitor) override {
        visitor.visitDirectory(*this);
    }
};

// ==================== 具体访问者1:计算总大小 ====================
class SizeCalculatorVisitor : public FileSystemVisitor {
private:
    size_t totalSize_ = 0;
    size_t fileCount_ = 0;
    size_t dirCount_ = 0;
    
    void traverse(FileSystemElement& elem) {
        elem.accept(*this);
    }
public:
    void visitFile(File& file) override {
        totalSize_ += file.getSize();
        ++fileCount_;
    }
    
    void visitDirectory(Directory& dir) override {
        ++dirCount_;
        for (const auto& child : dir.getChildren()) {
            traverse(*child);
        }
    }
    
    void printReport() const {
        std::cout << "📊 File System Report:\n"
                  << "   Total Size:  " << totalSize_ << " bytes\n"
                  << "   File Count:  " << fileCount_ << "\n"
                  << "   Dir Count:   " << dirCount_ << "\n";
    }
};

// ==================== 具体访问者2:目录树打印(外部遍历) ====================
class TreePrinterVisitor : public FileSystemVisitor {
private:
    int depth_ = 0;
    
    void printIndent() const {
        for (int i = 0; i < depth_; ++i) {
            std::cout << "│   ";
        }
    }
public:
    void visitFile(File& file) override {
        printIndent();
        std::cout << "📄 " << file.getName() 
                  << " (" << file.getSize() << "B)\n";
    }
    
    void visitDirectory(Directory& dir) override {
        if (depth_ == 0) {
            std::cout << "📁 " << dir.getName() << "/\n";
        } else {
            printIndent();
            std::cout << "📁 " << dir.getName() << "/\n";
        }
        
        // ⭐ Visitor自行控制递归 + 状态管理
        // depth_ 随调用栈自然增减,安全且无需RAII守卫
        ++depth_;
        for (const auto& child : dir.getChildren()) {
            child->accept(*this);
        }
        --depth_;
    }
};

// ==================== 构建测试文件系统 ====================
std::unique_ptr<Directory> buildTestFileSystem() {
    auto root = std::make_unique<Directory>("project");
    
    auto src = std::make_unique<Directory>("src");
    src->add(std::make_unique<File>("main.cpp", 1024));
    src->add(std::make_unique<File>("utils.cpp", 2048));
    
    auto include = std::make_unique<Directory>("include");
    include->add(std::make_unique<File>("utils.h", 512));
    include->add(std::make_unique<File>("config.h", 256));
    
    auto docs = std::make_unique<Directory>("docs");
    docs->add(std::make_unique<File>("README.md", 4096));
    docs->add(std::make_unique<File>("CHANGELOG.md", 8192));
    
    src->add(std::move(include));
    root->add(std::move(src));
    root->add(std::move(docs));
    root->add(std::make_unique<File>("CMakeLists.txt", 768));
    
    return root;
}

// ==================== 主函数 ====================
int main() {
    auto fs = buildTestFileSystem();
    
    std::cout << "========== Size Analysis ==========\n";
    SizeCalculatorVisitor sizeCalc;
    fs->accept(sizeCalc);
    sizeCalc.printReport();
    
    std::cout << "\n========== Directory Tree ==========\n";
    TreePrinterVisitor printer;
    fs->accept(printer);
    
    return 0;
}

3.3 运行输出

========== Size Analysis ==========
📊 File System Report:
   Total Size:  16896 bytes
   File Count:  6
   Dir Count:   4

========== Directory Tree ==========
📁 project/
│   📁 src/
│   │   📄 main.cpp (1024B)
│   │   📄 utils.cpp (2048B)
│   │   📁 include/
│   │   │   📄 utils.h (512B)
│   │   │   📄 config.h (256B)
│   📁 docs/
│   │   📄 README.md (4096B)
│   │   📄 CHANGELOG.md (8192B)
│   📄 CMakeLists.txt (768B)

3.4 关键设计决策:为什么选择外部遍历?

在上述实现中,Directory::accept() 不做递归,递归由 Visitor 自行控制。这是经过深思熟虑的选择:

维度内部遍历(accept中递归)外部遍历(Visitor自行递归)✅
遍历控制权在元素手中在访问者手中
状态安全性差(缩进等状态难以正确恢复)好(随调用栈自然增减)
遍历顺序可定制❌ 固定深度优先✅ 可自定义(过滤、剪枝、广度优先)
适用场景简单无状态聚合复杂有状态操作(打印、序列化、搜索)

⚠️ 专家建议:如果 Visitor 需要维护跨节点的状态(如缩进、序列化上下文),务必使用外部遍历。为此,Directory 暴露 getChildren() 是为换取遍历灵活性而做出的合理封装让步。


四、超越语法:访问者模式的五层思维模型

学会了“怎么写”只是入门,理解“为什么这样设计”才是精通。无论是扁平集合还是树形结构,访问者模式背后都蕴含着五种深刻的软件工程思维。

4.1 “数据与行为分离”的思维逆转

传统 OOP 强调封装至上,而访问者模式大胆地打破了封装。它承认一个现实:当操作的变化频率远高于数据结构的变化频率时,按“操作”来组织代码比按“对象”来组织代码更合理。这是面向方面编程(AOP) 在 OOP 中的一种手动实现。

4.2 “开放-封闭原则”的方向性选择

访问者模式揭示了一个残酷真相:你无法同时在两个维度上都满足 OCP,必须选择一个方向做出牺牲。

设计策略新增元素(数据)新增操作(算法)
传统继承/多态✅ 容易(加个子类即可)❌ 困难(改所有子类)
访问者模式❌ 困难(改所有Visitor)✅ 容易(加个新Visitor即可)

使用访问者模式,意味着你在架构层面做出了一个赌注:“我确信这个系统的数据类型是相对稳定的,而业务规则/算法是会频繁演进的。”这不是技术问题,而是对业务领域演化方向的预判能力。

4.3 “双重分发”的本质:二维问题的一维化

真正的操作取决于两个类型的组合(哪种元素 × 哪种访问者)。这是一个二维矩阵,而单分派只能处理一维。访问者模式就是把二维问题分解为两次一维分发的巧妙方案。accept 方法是类型恢复机制——把运行时信息转化为编译期静态类型,用一次额外间接调用换取完整的类型安全分发。

4.4 “控制反转”的思维

在传统模式中,shape->draw() 是对象主导。而在访问者模式中,shape->accept(visitor) 是对象交出控制权,把自己“交给”访问者。元素不再是行为的执行者,而是行为的提供者/载体。这与依赖注入、回调、IoC 容器的思想一脉相承。

4.5 “显式契约”优于“隐式假设”

没有访问者模式时,异构对象的新操作往往沦为脆弱的 dynamic_cast 链。访问者模式将这种隐式的、分散的、易出错的类型判断,转化为了显式的、集中的、编译器强制的契约:漏写任何一个 visit 重载都会导致编译错误。这是类型驱动设计(Type-Driven Design) 的核心思想——把运行时的不确定性转移到编译期。

思维模型全景图

访问者模式
思维模型

数据行为分离

打破封装的勇气

按关注点组织代码

AOP的手动实现

OCP方向性权衡

赌数据稳定+操作多变

架构级取舍

没有完美设计只有合适权衡

双重分发本质

类型恢复机制

二维问题→两次一维

用间接调用换类型安全

控制反转

元素是载体不是执行者

accept是IoC协议

与DI/回调同源

显式契约

运行时不确定→编译期约束

类型驱动设计

消除dynamic_cast地狱


五、现代 C++ 替代方案与选型

作为工程实践者,必须认识到访问者模式并非银弹。在现代 C++ 中,你有更多选择:

方案优点缺点适用场景
经典 Visitor完全解耦,易于扩展操作新增元素需改所有Visitor;样板代码多开放型深层继承体系(编译器AST)
std::variant + std::visit (C++17)类型安全,无虚函数开销,编译期检查封闭类型集,不适合深层继承元素类型固定且较少
Concepts + 模板 (C++20)零开销抽象,更自然的语法错误信息复杂,学习曲线陡峭高性能泛型算法库

选型建议

  • 如果元素类型固定且较少,优先使用 std::variant + std::visit
  • 如果元素类型开放且层次深(如编译器AST、文件系统树),经典访问者模式仍然是最佳选择。
  • 如果使用C++20以上,可以考虑用Concepts约束来简化Visitor接口。

六、总结与决策清单

当你下次考虑是否使用访问者模式时,不要只问“怎么写”,而要问自己这五个问题:

  1. 我的系统中,数据和操作哪个变化更快
  2. 我愿意为“易于扩展操作”付出“难以扩展数据”的代价吗?
  3. 我是否需要基于两种类型的组合来做分发?
  4. 元素是否应该放弃对自己操作的主导权
  5. 我是否希望编译器帮我强制检查类型覆盖的完整性

如果答案都是肯定的,那么访问者模式不仅是正确的技术选择,更是正确的思维方式。而当你的数据恰好是树形结构时,别忘了叫上它的老搭档——组合模式。组合模式搭建稳定的骨架,访问者模式赋予灵活的灵魂,这正是优秀架构设计的精髓所在。

💡 最后提醒:在实际工程中,注意循环引用防护(维护 visited set)、const 正确性(提供只读 Visitor 版本),以及返回值传递(通过成员变量或 std::optional)。细节决定成败。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值