精读 C++20 设计模式:行为型设计模式 — 访问者模式
精读 C++20 设计模式:行为型设计模式 — 访问者模式
访问者模式是另一个经典的设计模式——它把“算法”与“数据结构”分离:把作用于一组对象的操作从对象中抽离出来,以便在不修改这些对象类的情况下添加新的操作。
这里会涉及到分发这个事情:分发其实就是一组判断逻辑执行后执行不用的结果函数(运行时决定调用哪个函数实现的机制),常见的有单分发(single dispatch):只有调用对象的动态类型用于选择函数(例如常见的虚函数);和双分发(double dispatch):函数选择依赖两个对象的运行时类型(访问者 + 元素)。
访问者模式常用来实现双分发:元素把 this
传给访问者,从而同时根据元素类型和访问者类型采取不同行为。
什么是访问者模式
访问者模式把要在一组元素上执行的操作提取到一个独立的访问者(Visitor)对象中。每个元素类实现 accept(Visitor&)
,并在其中调用 visitor.visit(*this)
—— 这样访问者就能根据元素具体类型执行不同逻辑
侵入式访问者(经典实现) — 聊天室里的图形元素 Demo
这个示例展示经典、侵入式访问者:元素(Element)提供 accept(Visitor&)
,访问者声明针对每个具体元素的 visit
重载。示例用一个简单图形元素集合(Circle
、Rectangle
)并实现两个访问者:DrawVisitor
(绘制)与 AreaVisitor
(计算面积)。
// visitor_classic.cpp — 经典访问者示例(可直接编译)
#include <iostream>
#include <memory>
#include <vector>
#include <cmath>// 前向声明具体元素
struct Circle;
struct Rectangle;// 访问者接口:每新增一个具体元素,就要在这里添加一个 visit(C&) 函数
struct Visitor {virtual ~Visitor() = default;virtual void visit(const Circle& c) = 0;virtual void visit(const Rectangle& r) = 0;
};// 元素接口(Element)
struct Shape {virtual ~Shape() = default;virtual void accept(Visitor& v) const = 0;
};// 具体元素:Circle
struct Circle : Shape {double x,y, r;Circle(double x_, double y_, double r_) : x(x_), y(y_), r(r_) {}void accept(Visitor& v) const override { v.visit(*this); }
};// 具体元素:Rectangle
struct Rectangle : Shape {double x,y,w,h;Rectangle(double x_, double y_, double w_, double h_) : x(x_), y(y_), w(w_), h(h_) {}void accept(Visitor& v) const override { v.visit(*this); }
};// 一个绘制访问者(打印示意)
struct DrawVisitor : Visitor {void visit(const Circle& c) override {std::cout << "Draw Circle at (" << c.x << "," << c.y << ") r=" << c.r << "\n";}void visit(const Rectangle& r) override {std::cout << "Draw Rectangle at (" << r.x << "," << r.y << ") w=" << r.w << " h=" << r.h << "\n";}
};// 一个计算面积的访问者
struct AreaVisitor : Visitor {double total = 0.0;void visit(const Circle& c) override {total += M_PI * c.r * c.r;}void visit(const Rectangle& r) override {total += r.w * r.h;}
};int main() {std::vector<std::unique_ptr<Shape>> shapes;shapes.push_back(std::make_unique<Circle>(0,0,1));shapes.push_back(std::make_unique<Rectangle>(0,0,2,3));shapes.push_back(std::make_unique<Circle>(1,1,2));DrawVisitor draw;for (auto& s : shapes) s->accept(draw);AreaVisitor area;for (auto& s : shapes) s->accept(area);std::cout << "Total area: " << area.total << "\n";return 0;
}
Visitor
要注册对应的 visit(const Concrete&)
,其对应一个具体元素类型。它是开放给“新增操作”的扩展点。
Shape的Accept接口让元素把自己“交给”访问者——这一步实现了双分发:运行时元素类型决定 accept
的行为(虚函数),而 accept
内再调用 visitor.visit(*this)
,访问者根据元素静态类型选择正确的 visit
重载(编译时静态多态实际上在这里由函数签名+虚函数协作完成)。
要添加新元素类型,需要修改 Visitor
接口和所有现有访问者(侵入式缺点)。而添加新操作(即新的访问者类)不需要改元素类(这是访问者模式的优点)。
访问者的几个常见变种(含样例与说明)
访问者有很多变种,工程中常见的包括:
- 经典(侵入式)访问者(上面已展示)
- 优点:对新增操作友好(只需新增 Visitor)。
- 缺点:对新增元素类型(Concrete Element)不友好,需要修改 Visitor 接口及所有 Visitor 实现。
- Acyclic Visitor(无环访问者)
- 目标是降低对 Visitor 接口的集中修改:用每个元素定义自己专属的 visitor 接口(例如
CircleVisitable
,RectangleVisitable
),访问者根据是否实现某个接口来决定是否访问 —— 常借助 RTTI / dynamic_cast。这能避免集中修改一个超大的 Visitor 接口,但增加了接口数量与复杂度。 - 代码风格较多见于以插件/模块化为目的的系统。
- 目标是降低对 Visitor 接口的集中修改:用每个元素定义自己专属的 visitor 接口(例如
- 非侵入式访问者(外部分发)
- 不要求元素实现
accept
,而通过外部分发器使用 RTTI(dynamic_cast
)或 typeid 来识别元素真实类型并调用相应操作。实现简单,但性能与类型安全有所折中。 - 适合无法修改元素类(第三方库类型)但仍想“访问”其具体类型的场景。
- 不要求元素实现
- 基于
std::variant
+std::visit
的访问者(数据型替代)- 如果元素集合是封闭(disjoint union),
std::variant
+std::visit
提供了一个类型安全、非侵入式、无虚函数的替代方案。下面会给出完整示例。 - 优点:编译期类型安全、没有虚表开销、对新增变体需要修改
std::variant
类型列表(和新增元素一样的限制)。 - 缺点:变体必须是闭合的(编译时已知的类型集合),不适合运行时会动态新增元素类型的插件式场景。
- 如果元素集合是封闭(disjoint union),
- 返回值 / 结果型访问者
- 访问者除了“访问”外可以返回结果(例如
AreaVisitor
累计面积返回 double)。实现时visit
可以返回值或通过成员变量传回结果。 - 在多并发/不可变场景,推荐用返回值而非内部可变状态以便更好组合与测试。
- 访问者除了“访问”外可以返回结果(例如
std::variant + std::visit:现代 C++ 的替代思路
当元素集合是闭合的(即所有可能类型在编译时已知),std::variant
+ std::visit
是一个非常清晰、性能好的替代方案:它实现的是静态多态 + 运行时安全分发(访问者式的行为,但不需要侵入元素类)。
// variant_visit.cpp
#include <iostream>
#include <variant>
#include <vector>
#include <cmath>struct Circle { double x,y,r; };
struct Rectangle { double x,y,w,h; };using Shape = std::variant<Circle, Rectangle>;// helper overload for visit
template<class... Ts> struct Overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> Overloaded(Ts...) -> Overloaded<Ts...>;int main() {std::vector<Shape> shapes;shapes.push_back(Circle{0,0,1});shapes.push_back(Rectangle{0,0,2,3});shapes.push_back(Circle{1,1,2});// drawfor (auto& s : shapes) {std::visit(Overloaded{[](const Circle& c){ std::cout << "Draw Circle r=" << c.r << "\n"; },[](const Rectangle& r){ std::cout << "Draw Rect w=" << r.w << " h=" << r.h << "\n"; }}, s);}// areadouble total = 0;for (auto& s : shapes) {total += std::visit(Overloaded{[](const Circle& c)->double { return M_PI * c.r * c.r; },[](const Rectangle& r)->double { return r.w * r.h; }}, s);}std::cout << "Total area: " << total << "\n";return 0;
}
std::variant
+ std::visit
这个方案好在哪?首先他没有虚表开销(更适合高性能场景),编译器检查 std::visit
覆盖了变体里的每个类型(或你可以提供默认)。所有访问逻辑放在 std::visit
的重载组内(便于阅读)。
总结
问题:
- 在一组不同类型对象上执行多种不相关的操作,如果把操作代码散落到元素类中,类会臃肿;而需要在不修改元素类的前提下新增操作(或新增元素),这导致设计权衡。
如何解决:
- 访问者模式把操作(算法)抽离成访问者类,元素提供
accept
让访问者对元素进行“访问” —— 从而实现双分发(访问者 + 元素的运行时类型共同决定执行行为)。 - 在现代 C++ 中,根据闭合性与性能需求,也可以用
std::variant
+std::visit
来实现类似访问者的效果,但以“数据联合”的方式(非基于继承)。
优点:
- 新增操作非常方便(对经典访问者):只需添加一个新的
Visitor
,元素类不变。 - 逻辑集中:把复杂操作集中到访问者里,元素职责保持单一。
- 双分发:可以根据访问者类型和元素类型同时做出决策(这个是很多模式难以直接实现的)。
缺点:
- 侵入式接口:经典访问者需要在
Visitor
上为每个具体元素添加visit
,因此 新增元素类成本高(需要改动 Visitor 接口与所有 Visitor 实现)。 - 可扩展性权衡:如果需要两方面都经常扩展(既常新增元素又常新增操作),访问者并非全能解,要在架构上做权衡(拆域、拆接口、或选 variant + visit)。
- 实现复杂度:一些变体(Acyclic、Reflection、表驱动)更灵活但实现成本更高。
场景 / 需求 | 侵入式访问者 | std::variant + visit | 非侵入式 / RTTI | Acyclic Visitor |
---|---|---|---|---|
元素类可修改、类型较稳定 | ✅ 很合适(可扩展操作) | ✅ 可用(若集合闭合) | ✅ 可用(低侵入) | ✅(适合模块化) |
需要频繁新增操作(算法) | ✅(访问者友好) | ❌(需改 visit site) | ✅(可以外部添加) | ✅ |
性能敏感(避免虚调用) | ❌ 有虚调用 | ✅ 无虚调用 | ❌ RTTI 成本 | 取决实现 |
类型在运行时可扩展(插件) | ❌ 新类型需改 Visitor 接口 | ❌ 变体非动态 | ✅(dynamic_cast 支持) | ✅(为此设计) |
简单、少量类型 | ✅/简单 | ✅ 简洁 | ✅ | 复杂/过度 |