目录
访问者模式的定义
访问者模式(Visitor)的 GoF 定义是:这是一种行为型设计模式,表示一个作用于某对象结构中各元素的操作。访问者模式使你可以在不改变各元素的类的前提下,定义作用于这些元素的新操作。
这里的核心前提是”元素类型相对稳定”。
你的对象结构里有哪些种类的节点,基本是固定的,但针对这些节点的操作会不断增加。
比如一个文档解析器,文档里的元素类型就那么几种:段落、图片、表格。但你要对文档做的操作可能有很多种:导出 HTML、导出 Markdown、统计字数、语法检查,以后还会加新的。
访问者模式的思路是把操作从元素类里解耦出来,放进独立的访问者对象。元素只需要提供一个入口方法,把自身引用传给访问者,由访问者根据元素的具体类型分发到对应的处理方法。
这个机制叫双重分派(Double Dispatch):第一重分派是元素调访问者的入口方法,第二重分派是访问者根据参数类型选择对应的重载方法。两步合起来,运行时就能精确命中”这个访问者 + 这个元素类型”的组合。
访问者模式用于解决什么问题
如果不用访问者,操作逻辑会直接写进每个元素类里。
设想一个文档系统,有段落、图片、表格三种元素,要支持导出 HTML 和导出 Markdown 两种操作。
最朴素的写法是在每个元素类里都写两个导出方法:
class Paragraph {
public:
string toHTML() { /* ... */ }
string toMarkdown() { /* ... */ }
};
class Image {
public:
string toHTML() { /* ... */ }
string toMarkdown() { /* ... */ }
};
class Table {
public:
string toHTML() { /* ... */ }
string toMarkdown() { /* ... */ }
};
元素有 E 种、操作有 O 种,朴素写法就在 E 个类里各写 O 个方法,共 E × O 段逻辑。
3 种元素配 2 种操作是 6 段逻辑,加一种操作(比如字数统计)变成 9 段,分散在 3 个类里。
每加一种操作,每个元素类都得改一遍,往里面追加新方法。
更麻烦的是,同一种操作的逻辑被拆散到不同元素类里。比如导出 HTML 这个实现就分散在 Paragraph::toHTML、Image::toHTML、Table::toHTML 三个地方,修改一处能力就要同时改三个类。
访问者模式就是要解决这个问题的。它把每种操作单独抽成一个访问者类,把”导出 HTML""字数统计”这类逻辑从元素类里挪出来,集中到对应的访问者中。元素类不再持有任何具体操作,只保留一个 accept 方法作为统一入口,由它把自身交给访问者处理。这样一来,新增一种操作只要加一个访问者类,已有的元素类完全不用改动,操作逻辑也收拢到一处,不再散落在各个元素类里。
两种实现方式的规模对比如下。
| 元素数 E | 操作数 O | 朴素写法 E×O | 访问者写法 E+O |
|---|---|---|---|
| 3 | 2 | 6 | 5 |
| 3 | 3 | 9 | 6 |
| 4 | 4 | 16 | 8 |
操作种类越多,访问者模式带来的优势就越明显。
访问者模式 UML 类图
访问者模式涉及五个角色:Visitor(访问者接口)、ConcreteVisitor(具体访问者)、Element(元素接口)、ConcreteElement(具体元素)、ObjectStructure(对象结构,负责持有并遍历元素,非必需但常见)。
双重分派就发生在 Element 的 accept 方法与 Visitor 的 visit 方法之间。
flowchart TB
Visitor["Visitor<br/>---<br/><<interface>> Visitor<br/>---<br/>+visit(Paragraph) : void<br/>+visit(Image) : void<br/>+visit(Table) : void"]
HTMLExporter["HTMLExporter<br/>---<br/>ConcreteVisitor<br/>---<br/>+visit(Paragraph) : void<br/>+visit(Image) : void<br/>+visit(Table) : void"]
MarkdownExporter["MarkdownExporter<br/>---<br/>ConcreteVisitor<br/>---<br/>+visit(Paragraph) : void<br/>+visit(Image) : void<br/>+visit(Table) : void"]
Element["Element<br/>---<br/><<interface>> Element<br/>---<br/>+accept(Visitor) : void"]
Paragraph["Paragraph<br/>---<br/>ConcreteElement<br/>-text : string<br/>---<br/>+accept(Visitor) : void<br/>+getText() : string"]
Image["Image<br/>---<br/>ConcreteElement<br/>-src : string<br/>---<br/>+accept(Visitor) : void<br/>+getSrc() : string"]
Table["Table<br/>---<br/>ConcreteElement<br/>-rows : vector<br/>---<br/>+accept(Visitor) : void<br/>+getRows() : vector"]
Doc["Document<br/>---<br/>ObjectStructure<br/>-elements : list<br/>---<br/>+add(Element) : void<br/>+accept(Visitor) : void"]
Visitor -.->|实现| HTMLExporter
Visitor -.->|实现| MarkdownExporter
Element ---|继承| Paragraph
Element ---|继承| Image
Element ---|继承| Table
Doc -->|聚合| Element
Element ==>|"双重分派"| Visitor
style Visitor fill:#dbeafe,stroke:#1e40af,stroke-width:2px
style HTMLExporter fill:#dbeafe,stroke:#1e40af
style MarkdownExporter fill:#dbeafe,stroke:#1e40af
style Element fill:#fef3c7,stroke:#92400e,stroke-width:2px
style Paragraph fill:#fef3c7,stroke:#92400e
style Image fill:#fef3c7,stroke:#92400e
style Table fill:#fef3c7,stroke:#92400e
style Doc fill:#dcfce7,stroke:#15803d,stroke-width:2px
UML 对象交互图:工作流程
以”用 HTML 导出器遍历整个文档”为例,看一次访问是怎么发生的。
sequenceDiagram
participant C as 客户端 Client
participant D as 文档 Document(ObjectStructure)
participant P as 段落 Paragraph(Element)
participant V as HTMLExporter(Visitor)
C->>D: accept(htmlExporter)
D->>P: accept(htmlExporter)
Note over P: 元素把自己交给访问者<br/>这就是第一重分派
P->>V: visit(this)
Note over V: 访问者根据参数类型<br/>选择对应的重载方法<br/>这就是第二重分派
V-->>P: 处理完成
P-->>D: 返回
D-->>C: 遍历结束
关键动作是:对象结构遍历内部元素,逐个调用 accept;每个元素在 accept 里把自己传给访问者的 visit 方法;访问者根据参数的具体类型调用对应的重载版本。两步合起来实现了双重分派,确保运行时能精确命中”这个访问者 + 这个元素类型”的组合。
伪代码:将 UML 类转变为实现
下面把 UML 里的五个角色逐个落成代码。
以下代码为说明原理的示意性伪代码,省略头文件与 include guard 等内容。
先看 Visitor 接口,它为每种元素类型声明一个 visit 重载:
class Paragraph;
class Image;
class Table;
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(Paragraph* p) = 0;
virtual void visit(Image* img) = 0;
virtual void visit(Table* tbl) = 0;
};
HTMLExporter 和 MarkdownExporter 是两个 ConcreteVisitor,各自实现 visit 重载:
class HTMLExporter : public Visitor {
public:
void visit(Paragraph* p) override {
// 输出 <p>...</p>
}
void visit(Image* img) override {
// 输出 <img src="..."/>
}
void visit(Table* tbl) override {
// 输出 <table>...</table>
}
};
class MarkdownExporter : public Visitor {
public:
void visit(Paragraph* p) override {
// 输出纯文本段落
}
void visit(Image* img) override {
// 输出 
}
void visit(Table* tbl) override {
// 输出 Markdown 表格
}
};
Element 是元素接口,必须能接收访问者:
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor* v) = 0; // 双重分派入口
};
Paragraph 是 ConcreteElement,accept 里把 this 传给访问者:
class Paragraph : public Element {
std::string text;
public:
explicit Paragraph(const std::string& t) : text(t) {}
const std::string& getText() const { return text; }
void accept(Visitor* v) override {
v->visit(this); // 第一重分派:把自己交给访问者
}
};
Image 同理,只是携带的数据不同:
class Image : public Element {
std::string src;
public:
explicit Image(const std::string& s) : src(s) {}
const std::string& getSrc() const { return src; }
void accept(Visitor* v) override {
v->visit(this); // this 静态类型是 Image*,编译器才能命中 visit(Image*)
}
};
Table 也一样,持有表格数据:
class Table : public Element {
std::vector<std::vector<std::string>> rows;
public:
explicit Table(const std::vector<std::vector<std::string>>& r) : rows(r) {}
const std::vector<std::vector<std::string>>& getRows() const { return rows; }
void accept(Visitor* v) override {
v->visit(this);
}
};
Document 是 ObjectStructure,负责持有元素并驱动访问:
class Document {
std::vector<std::unique_ptr<Element>> elements;
public:
void add(std::unique_ptr<Element> e) {
elements.push_back(std::move(e));
}
void accept(Visitor* v) {
for (auto& e : elements) {
e->accept(v); // 逐个元素触发访问
}
}
};
Client 端用起来是这样:
int main() {
Document doc;
doc.add(std::make_unique<Paragraph>("Hello World"));
doc.add(std::make_unique<Image>("photo.png"));
doc.add(std::make_unique<Table>({{"A", "B"}, {"1", "2"}}));
HTMLExporter html;
doc.accept(&html); // 整个文档导出为 HTML
MarkdownExporter md;
doc.accept(&md); // 同一份文档,换访问者就换输出格式
}
以上代码完整走通了双重分派的全过程:Document 遍历元素,每个元素在 accept 里把 this 交给访问者,访问者通过重载命中正确的处理方法。
C++17 用 std::variant 的替代写法
如果你的元素类型不多、也不常变,可以用 std::variant 把元素收进一个联合类型,再用 std::visit 完成分派,省掉每个元素类的 accept 方法和整条 Visitor 继承链。
元素直接用普通数据结构,不再继承 Element,也不需要 accept:
struct Paragraph { std::string text; };
struct Image { std::string src; };
struct Table { std::vector<std::vector<std::string>> rows; };
using Element = std::variant<Paragraph, Image, Table>;
导出逻辑变成一组重载的访问器对象,交给 std::visit 自动按当前持有类型分派:
struct HTMLExporter {
void operator()(const Paragraph& p) const { /* 输出 <p>...</p> */ }
void operator()(const Image& img) const { /* 输出 <img src="..."/> */ }
void operator()(const Table& tbl) const { /* 输出 <table>...</table> */ }
};
struct MarkdownExporter {
void operator()(const Paragraph& p) const { /* 输出纯文本段落 */ }
void operator()(const Image& img) const { /* 输出  */ }
void operator()(const Table& tbl) const { /* 输出 Markdown 表格 */ }
};
Document 改成持有 variant 列表,导出时直接遍历并 std::visit:
class Document {
std::vector<Element> elements;
public:
void add(Element e) { elements.push_back(std::move(e)); }
template <typename Exporter>
void exportWith(const Exporter& exp) const {
for (const auto& e : elements) {
std::visit(exp, e); // 按实际类型分派到 Exporter 的对应 operator()
}
}
};
Client 端用起来同样直观,换导出格式就是换访问器:
int main() {
Document doc;
doc.add(Paragraph{"Hello World"});
doc.add(Image{"photo.png"});
doc.add(Table{{{ "A", "B" }, { "1", "2" }}});
doc.exportWith(HTMLExporter{}); // 导出为 HTML
doc.exportWith(MarkdownExporter{}); // 同一份文档,换访问器就换格式
}
这种写法和前面的经典访问者效果相当,但元素之间没有继承关系,类更轻。代价是元素类型被写死在 std::variant<…> 里,加一种新元素就得改这个类型列表和所有访问器,公共接口也要手动在各结构体间保持一致。两种写法都适合”元素稳定、操作偏多”的场景,选择更多取决于你对多态继承和模板代码的偏好。
落到工程里要注意的几点
访问者接口和元素接口的析构函数都要声明为虚函数。 否则通过基类指针删除具体对象时,派生类的析构不会被调用,行为未定义。这两个接口都涉及多态删除,缺了 virtual 析构就是隐患。
新增元素类型代价很大。 访问者模式的天平是偏向”操作多、元素稳定”的。如果元素类型经常变,每加一种元素,Visitor 接口和所有已有的 ConcreteVisitor 都得加一个 visit 重载,修改面比朴素写法还大。所以这个模式只在元素类型相对固定的前提下才划算。
accept 方法里的 this 指针必须指向具体类型。 C++ 的函数重载在编译期按参数的静态类型匹配。如果 accept 里写 v->visit((Element*)this),编译器看到的是 Element* 参数,而 Visitor 接口里没有 visit(Element*) 这个重载,编译就过不了。必须写 v->visit(this),此时 this 的静态类型是 ConcreteElement*,编译器才能正确匹配到对应的重载。这是双重分派能在 C++ 里成立的关键细节。