79、STL与访问者模式:访问者模式、双重分派、元素遍历

访问者模式,说白了就是「把操作从对象身上剥离出来」。我刚开始接触这个模式时,总觉得它绕来绕去的——明明可以直接在类里加个方法,干嘛非要搞个外部访问者?直到我在一个大型图形编辑器项目中,被十几个不同形状类的「导出」功能折磨得够呛,才真正体会到它的价值。

你想想看,如果每个形状类都要实现 exportToJSON()exportToXML()exportToBinary()……那代码会膨胀成什么样?访问者模式就是来解决这个问题的。

核心思想:双重分派

C++ 的虚函数只支持「单分派」——调用哪个函数由对象的动态类型决定,但参数类型是静态绑定的。访问者模式通过两次分派,实现了「根据对象类型 + 访问者类型」共同决定行为的效果。

第一次分派:元素对象调用 accept(Visitor&),根据自身类型调用对应的 visit() 重载。
第二次分派:访问者内部,根据传入的元素类型,执行对应的处理逻辑。

关键点:双重分派让「操作」和「数据结构」彻底解耦。新增一种操作,只需要加一个新的访问者类,不用改任何元素类。

STL 中的访问者模式

STL 虽然没有直接提供「访问者模式」的类,但它的迭代器 + 算法体系,本质上就是一种访问者思想的体现。比如 std::for_each,它遍历容器元素,对每个元素调用一个可调用对象——这不就是访问者模式吗?

不过,STL 的算法是「外部迭代器」风格,而经典的访问者模式是「内部迭代器」风格。前者由调用者控制遍历节奏,后者由元素内部决定何时接受访问。

一个完整的例子:图形元素导出

假设我们有圆形、矩形、三角形三种图形,需要支持 JSON 和 XML 两种导出格式。用访问者模式来实现:

#include <iostream>
#include <vector>
#include <memory>
#include <sstream>

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

// 访问者基类
class Visitor {
public:
    virtual ~Visitor() = default;
    virtual void visit(Circle& c) = 0;
    virtual void visit(Rectangle& r) = 0;
    virtual void visit(Triangle& t) = 0;
};

// 元素基类
class Shape {
public:
    virtual ~Shape() = default;
    virtual void accept(Visitor& v) = 0;
};

// 具体元素
class Circle : public Shape {
public:
    double radius = 5.0;
    void accept(Visitor& v) override {
        v.visit(*this);  // 第一次分派:this 的类型是 Circle&
    }
};

class Rectangle : public Shape {
public:
    double width = 4.0, height = 6.0;
    void accept(Visitor& v) override {
        v.visit(*this);
    }
};

class Triangle : public Shape {
public:
    double base = 3.0, height = 4.0;
    void accept(Visitor& v) override {
        v.visit(*this);
    }
};

// 具体访问者:JSON 导出
class JSONExporter : public Visitor {
public:
    void visit(Circle& c) override {
        ss << "{ \"type\": \"circle\", \"radius\": " << c.radius << " }";
    }
    void visit(Rectangle& r) override {
        ss << "{ \"type\": \"rectangle\", \"width\": " << r.width
           << ", \"height\": " << r.height << " }";
    }
    void visit(Triangle& t) override {
        ss << "{ \"type\": \"triangle\", \"base\": " << t.base
           << ", \"height\": " << t.height << " }";
    }
    std::string result() const { return ss.str(); }
private:
    std::ostringstream ss;
};

// 具体访问者:XML 导出
class XMLExporter : public Visitor {
public:
    void visit(Circle& c) override {
        ss << "<circle radius=\"" << c.radius << "\"/>";
    }
    void visit(Rectangle& r) override {
        ss << "<rectangle width=\"" << r.width
           << "\" height=\"" << r.height << "\"/>";
    }
    void visit(Triangle& t) override {
        ss << "<triangle base=\"" << t.base
           << "\" height=\"" << t.height << "\"/>";
    }
    std::string result() const { return ss.str(); }
private:
    std::ostringstream ss;
};

// 使用示例
int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Circle>());
    shapes.push_back(std::make_unique<Rectangle>());
    shapes.push_back(std::make_unique<Triangle>());

    JSONExporter json;
    XMLExporter xml;

    for (auto& s : shapes) {
        s->accept(json);
        s->accept(xml);
    }

    std::cout << "JSON: " << json.result() << "\n";
    std::cout << "XML:  " << xml.result() << "\n";
    return 0;
}

访问者模式的核心流程

访问者模式双重分派流程 客户端 Shape (元素基类) Circle / Rectangle / Triangle Visitor (访问者基类) JSONExporter / XMLExporter 调用 accept() 第一次分派 第二次分派 visit(*this) 输出:JSON / XML 字符串 生成结果 双重分派:先根据元素类型选择 visit() 重载,再根据访问者类型执行具体逻辑

避坑指南

我曾经在一个项目中,给访问者模式加了十几个元素类型,结果每次新增元素都要修改所有访问者基类——这违反了开闭原则。后来我改用 std::variant + std::visit 才彻底解决。

所以我的建议是:如果元素类型经常变化,别用经典的访问者模式。用 std::variant 配合 std::visit 更灵活。

STL 风格的访问者:std::variant + std::visit

C++17 引入的 std::variantstd::visit,本质上就是访问者模式的现代实现。它不需要继承,不需要虚函数,性能还更好。

#include <iostream>
#include <variant>
#include <vector>
#include <string>

struct Circle   { double radius; };
struct Rectangle { double width, height; };
struct Triangle  { double base, height; };

using Shape = std::variant<Circle, Rectangle, Triangle>;

struct JSONExporter {
    std::string operator()(const Circle& c) {
        return "{ \"type\": \"circle\", \"radius\": " + std::to_string(c.radius) + " }";
    }
    std::string operator()(const Rectangle& r) {
        return "{ \"type\": \"rectangle\", \"width\": " + std::to_string(r.width)
             + ", \"height\": " + std::to_string(r.height) + " }";
    }
    std::string operator()(const Triangle& t) {
        return "{ \"type\": \"triangle\", \"base\": " + std::to_string(t.base)
             + ", \"height\": " + std::to_string(t.height) + " }";
    }
};

int main() {
    std::vector<Shape> shapes = {
        Circle{5.0},
        Rectangle{4.0, 6.0},
        Triangle{3.0, 4.0}
    };

    for (const auto& s : shapes) {
        std::cout << std::visit(JSONExporter{}, s) << "\n";
    }
    return 0;
}

我个人习惯是:如果元素类型固定且不多(比如 3-5 个),用 std::variant + std::visit。如果元素类型很多且需要扩展,用经典的访问者模式。如果既要扩展性又要性能……嗯,那得看具体场景了。

什么时候用访问者模式?

场景 推荐方案 原因
元素类型固定,操作经常变 经典访问者模式 新增操作只需加访问者,不改元素
元素类型经常变,操作固定 普通虚函数 新增元素只需加子类,不改操作
元素类型少且固定 std::variant + std::visit 性能好,代码简洁,无继承
需要遍历复杂对象结构 访问者模式 + 递归遍历 适合树形结构(如 AST)

访问者模式不是银弹。我在项目中见过有人硬套这个模式,结果代码比不用还复杂。记住:模式是工具,不是目的。当你的「操作」确实需要独立于「数据结构」变化时,访问者模式才真正发光。


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