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;
}
访问者模式的核心流程
避坑指南
我曾经在一个项目中,给访问者模式加了十几个元素类型,结果每次新增元素都要修改所有访问者基类——这违反了开闭原则。后来我改用 std::variant + std::visit 才彻底解决。
所以我的建议是:如果元素类型经常变化,别用经典的访问者模式。用 std::variant 配合 std::visit 更灵活。
STL 风格的访问者:std::variant + std::visit
C++17 引入的 std::variant 和 std::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