访问者模式:模式动机与定义

访问者模式,说白了就是解决一个老问题:如何在不修改已有类的前提下,给这些类增加新的操作。你想想看,我们写代码时经常遇到这样的场景——一个稳定的对象结构(比如抽象语法树、UI控件树、文件系统),但操作这些对象的方式却总在变。比如,给所有控件增加一个“导出为JSON”的功能,或者给所有语法树节点增加一个“类型检查”的步骤。

我个人习惯把访问者模式看作“晚绑定操作”。它把数据结构和操作分离,让操作可以独立演化。嗯,这里要注意:它特别适合结构稳定但操作频繁变化的场景。如果结构本身也经常变,那访问者模式反而会带来灾难——你每次加一个新类,都要修改所有访问者。

核心定义:访问者模式(Visitor Pattern)表示一个作用于某对象结构中的各元素的操作。它使你可以在不改变各元素类的前提下定义作用于这些元素的新操作。

模式动机——我为什么需要它?

我在项目中遇到过这样一个场景:一个图形编辑器,里面有圆形、矩形、线条等各种形状。一开始,我们只需要计算面积。后来,需求来了:要导出SVG、要序列化成JSON、要检测碰撞、要生成缩略图……如果每次都在形状类里加方法,那这些类会变得臃肿不堪,而且每次修改都可能引入bug。

更糟糕的是,这些操作往往和形状本身的核心逻辑无关。你想想看,一个圆形类,它的本职工作是存储半径和圆心,为什么要知道怎么导出SVG?这违反了单一职责原则。

访问者模式就是来解这个耦的。它让你把“导出SVG”这个操作封装成一个独立的访问者类,然后让每个形状类“接待”这个访问者。形状类只需要说一句:“我接受你的访问,这是我的数据,你看着办吧。”

角色与结构

访问者模式有四个核心角色,我习惯把它们分成两组:结构组操作组

角色 名称 职责
Visitor(访问者) 抽象访问者 为每个具体元素类声明一个访问方法,如 VisitConcreteElementA()
ConcreteVisitor(具体访问者) 具体访问者 实现每个访问方法,定义对具体元素的操作
Element(元素) 抽象元素 声明一个 Accept 方法,接受一个访问者作为参数
ConcreteElement(具体元素) 具体元素 实现 Accept 方法,通常就是调用 visitor->Visit(this)

结构上,访问者模式的核心是双重分派。第一次分派:客户端调用 element->Accept(visitor),根据元素的具体类型进入对应的 Accept 实现。第二次分派:在 Accept 内部,调用 visitor->Visit(this),根据访问者的具体类型进入对应的 Visit 方法。这样,一次调用就同时确定了元素和访问者的具体类型。

访问者模式结构图 抽象访问者 +VisitConcreteA() +VisitConcreteB() 具体访问者1 (如:导出JSON) 具体访问者2 (如:计算面积) 抽象元素 +Accept(Visitor) 具体元素A (如:圆形) 具体元素B (如:矩形) 对象结构 (集合/容器) Visit(this) Visit(this) 客户端 element->Accept(visitor)

C++代码实现

下面我给出一个完整的C++实现。这个例子模拟了图形编辑器中的圆形和矩形,以及两个访问者:一个计算面积,一个导出SVG。

// 前置声明
class Circle;
class Rectangle;

// 抽象访问者
class Visitor {
public:
    virtual ~Visitor() = default;
    virtual void VisitCircle(Circle* c) = 0;
    virtual void VisitRectangle(Rectangle* r) = 0;
};

// 抽象元素
class Shape {
public:
    virtual ~Shape() = default;
    virtual void Accept(Visitor* v) = 0;
};

// 具体元素:圆形
class Circle : public Shape {
public:
    double radius;
    explicit Circle(double r) : radius(r) {}

    void Accept(Visitor* v) override {
        v->VisitCircle(this);  // 第二次分派
    }
};

// 具体元素:矩形
class Rectangle : public Shape {
public:
    double width, height;
    Rectangle(double w, double h) : width(w), height(h) {}

    void Accept(Visitor* v) override {
        v->VisitRectangle(this);  // 第二次分派
    }
};

// 具体访问者1:计算面积
class AreaVisitor : public Visitor {
public:
    void VisitCircle(Circle* c) override {
        double area = 3.14159 * c->radius * c->radius;
        std::cout << "圆形面积: " << area << std::endl;
    }
    void VisitRectangle(Rectangle* r) override {
        double area = r->width * r->height;
        std::cout << "矩形面积: " << area << std::endl;
    }
};

// 具体访问者2:导出SVG
class SvgExporter : public Visitor {
public:
    void VisitCircle(Circle* c) override {
        std::cout << "<circle cx=\"0\" cy=\"0\" r=\"" 
                  << c->radius << "\" />" << std::endl;
    }
    void VisitRectangle(Rectangle* r) override {
        std::cout << "<rect width=\"" << r->width 
                  << "\" height=\"" << r->height << "\" />" << std::endl;
    }
};

// 客户端使用
int main() {
    std::vector<Shape*> shapes;
    shapes.push_back(new Circle(5.0));
    shapes.push_back(new Rectangle(3.0, 4.0));

    AreaVisitor areaCalc;
    SvgExporter svgOut;

    for (auto* s : shapes) {
        s->Accept(&areaCalc);  // 第一次分派
        s->Accept(&svgOut);
    }

    // 清理内存...
    return 0;
}
个人经验:我在实际项目中,经常把访问者模式和组合模式一起用。对象结构往往是一棵树(比如AST),访问者遍历这棵树,在每个节点上执行操作。这时候,访问者里通常会有一个“遍历逻辑”,比如先访问子节点,再访问当前节点。我建议把遍历逻辑也封装在访问者里,而不是放在元素类中,这样元素类可以保持纯粹。

双分派技术

双分派是访问者模式的灵魂。为什么叫“双分派”?因为一次操作请求,需要两次运行时类型判断才能确定最终调用的函数。

第一次分派:shape->Accept(visitor)。这里,shape 的实际类型决定了调用 Circle::Accept 还是 Rectangle::Accept。这是C++的虚函数机制,属于单分派。

第二次分派:在 Circle::Accept 内部,调用 visitor->VisitCircle(this)。这里,visitor 的实际类型决定了调用 AreaVisitor::VisitCircle 还是 SvgExporter::VisitCircle。这又是一次单分派。

两次分派合起来,就实现了“根据元素类型和访问者类型共同决定行为”的效果。这就是双分派。

避坑指南:我曾经在一个项目中,试图用 dynamic_cast 来模拟双分派。代码写出来又臭又长,而且每次新增元素类型都要改一堆 switch-case。后来重构为访问者模式,代码清晰多了。记住:访问者模式是C++中实现双分派的标准方式,不要自己造轮子。

为什么C++默认不支持双分派?因为C++的虚函数只根据调用对象的实际类型进行分派(单分派),而参数类型是静态绑定的。访问者模式巧妙地通过两次单分派组合出了双分派的效果。你想想看,如果没有这个模式,你要实现“根据两个对象的实际类型决定行为”,就只能用嵌套的 if-else 或者 typeid,那代码维护起来简直是噩梦。

嗯,这里还要提一个细节:访问者模式中的 Visit 方法通常是重载的,参数类型不同。这其实是静态多态(函数重载)和动态多态(虚函数)的结合使用。静态多态负责根据参数类型选择 Visit 版本,动态多态负责根据访问者实际类型选择 Visit 实现。

核心要点回顾:
  • 访问者模式适用于结构稳定、操作多变的场景
  • 核心机制是双分派:两次单分派组合而成
  • 元素类只负责 Accept,操作逻辑全部封装在访问者中
  • 新增操作只需新增一个访问者类,无需修改元素类
  • 但如果经常新增元素类型,访问者模式会带来大量修改——这是它的主要缺点

最后说一句:访问者模式不是银弹。我在项目中见过有人滥用它,把简单的操作也套上访问者,结果代码变得过度设计。我的建议是:当你发现某个对象结构上的操作频繁变化,而且这些操作之间没有共同接口时,才考虑使用访问者模式。否则,简单的虚函数或者函数重载可能更合适。

公众号:蓝海资料掘金营,微信 deep3321