26、访问者模式:模式动机与定义、角色与结构、C++代码实现、双分派技术
模式动机:为什么需要访问者?
我先问你一个问题:你有一个对象结构,里面装着各种不同类型的元素。比如一个编译器里的抽象语法树,有变量声明、函数调用、赋值语句……现在,你想给这棵树加一个“代码格式化”操作。你会怎么做?
最直接的想法:在每个节点类里加一个 format() 方法。嗯,这确实能干活。但过了一周,产品经理又来了:“我们需要加一个‘变量重命名’功能。”你又得去每个类里加 rename()。再下一周,“加一个‘死代码消除’”。你想想看,每加一个操作,就要改一遍所有节点类。这违反了开闭原则——对扩展开放,对修改关闭。
我在项目中遇到过类似的情况。当时我们维护一个图形编辑器,图元类型有十几种。每次加一个新操作(比如导出SVG、计算面积、碰撞检测),都要改一遍所有图元类。代码变得又臭又长,合并分支时冲突不断。后来我重构了这部分,用访问者模式把操作和数据结构分离了。嗯,世界清净了。
访问者模式的核心思想就是:把操作(算法)从对象结构中抽离出来。你定义一组访问者,每个访问者实现针对不同元素的操作。元素只负责接受访问者,然后把自身“暴露”给访问者。这样,加新操作只需要加一个新的访问者类,不用改已有的元素类。
模式定义与角色结构
说白了,访问者模式让你能在不改变元素类的前提下,定义作用于这些元素的新操作。它的角色结构如下:
| 角色 | 职责 | 我的一点经验 |
|---|---|---|
| Visitor(访问者) | 为每个具体元素类声明一个访问方法,如 visitConcreteElementA() |
方法名最好带上元素类型,避免重载混淆 |
| ConcreteVisitor(具体访问者) | 实现每个访问方法,定义针对该元素的具体操作 | 每个访问者就是一个独立的“操作” |
| Element(元素) | 声明一个 accept(Visitor&) 方法,参数是访问者 |
这个方法通常只有一行:调用访问者的对应 visit 方法 |
| ConcreteElement(具体元素) | 实现 accept(),将自己 this 传给访问者 |
注意传的是 this 指针,类型要匹配 |
| ObjectStructure(对象结构) | 管理元素集合,提供遍历方式 | 可以是数组、列表、树等,负责调用每个元素的 accept() |
这里有个关键点:双分派。为什么叫“双分派”?因为操作最终由两个因素共同决定:访问者的类型 + 元素的类型。普通的虚函数是单分派——只根据对象的实际类型调用一个方法。而访问者模式通过两次调用(元素.accept(访问者) → 访问者.visit(元素))实现了双分派。我刚开始学的时候也觉得绕,但写几个例子就明白了。
SVG 结构图:访问者模式核心逻辑
C++ 代码实现:一个完整的例子
我们来看一个具体的例子。假设我们有一个文档结构,包含两种元素:段落(Paragraph)和图片(Image)。现在我们要实现两个操作:一个是“HTML导出”,一个是“字数统计”。
先定义元素接口和具体元素:
// 前置声明
class Visitor;
// 元素接口
class Element {
public:
virtual ~Element() = default;
virtual void accept(Visitor& v) = 0;
};
// 具体元素:段落
class Paragraph : public Element {
public:
std::string text;
explicit Paragraph(const std::string& t) : text(t) {}
void accept(Visitor& v) override {
v.visit(*this); // 关键:把自己传给访问者
}
};
// 具体元素:图片
class Image : public Element {
public:
std::string src;
explicit Image(const std::string& s) : src(s) {}
void accept(Visitor& v) override {
v.visit(*this);
}
};
然后定义访问者接口和具体访问者:
// 访问者接口
class Visitor {
public:
virtual ~Visitor() = default;
virtual void visit(Paragraph& p) = 0;
virtual void visit(Image& i) = 0;
};
// 具体访问者:HTML导出
class HtmlExporter : public Visitor {
public:
void visit(Paragraph& p) override {
result += "<p>" + p.text + "</p>\n";
}
void visit(Image& i) override {
result += "<img src='" + i.src + "' />\n";
}
std::string getResult() const { return result; }
private:
std::string result;
};
// 具体访问者:字数统计
class WordCounter : public Visitor {
public:
void visit(Paragraph& p) override {
// 简单按空格分词统计
int count = 0;
bool inWord = false;
for (char c : p.text) {
if (std::isspace(c)) {
inWord = false;
} else if (!inWord) {
++count;
inWord = true;
}
}
total += count;
}
void visit(Image& i) override {
// 图片不算字数,但可以加个占位计数
total += 1; // 每张图片算1个字
}
int getTotal() const { return total; }
private:
int total = 0;
};
最后是对象结构和客户端代码:
// 对象结构:文档
class Document {
public:
void addElement(std::unique_ptr<Element> elem) {
elements.push_back(std::move(elem));
}
void accept(Visitor& v) {
for (auto& elem : elements) {
elem->accept(v); // 遍历并接受访问者
}
}
private:
std::vector<std::unique_ptr<Element>> elements;
};
// 客户端使用
int main() {
Document doc;
doc.addElement(std::make_unique<Paragraph>("Hello, world!"));
doc.addElement(std::make_unique<Image>("logo.png"));
doc.addElement(std::make_unique<Paragraph>("This is a visitor pattern demo."));
HtmlExporter exporter;
doc.accept(exporter);
std::cout << "HTML输出:\n" << exporter.getResult();
WordCounter counter;
doc.accept(counter);
std::cout << "总字数: " << counter.getTotal() << std::endl;
return 0;
}
visit(ConcreteElement& elem) 而不是 visit(ConcreteElement* elem)。引用更安全,而且语义上“访问者访问一个元素”更自然。
双分派技术:到底是怎么工作的?
你可能会问:为什么不用一个普通的虚函数搞定?比如在 Element 里定义一个纯虚函数 doOperation(),然后每个子类实现?那样的话,操作是写死在元素里的。而访问者模式把操作移到了外部。
双分派的关键在于两次函数调用:
- 第一次分派:
element.accept(visitor)。这里根据 element 的实际类型(Paragraph 还是 Image),调用对应的accept()方法。 - 第二次分派:在
accept()内部,调用visitor.visit(*this)。这里的this类型是确定的(比如Paragraph*),所以编译器会调用Visitor::visit(Paragraph&)这个重载版本。
两次分派合起来,就实现了“根据访问者类型 + 元素类型”共同决定行为。这就是双分派。C++ 本身只支持单分派(虚函数),但通过访问者模式,我们模拟出了双分派的效果。
accept() 里传 this 时,一定要传 *this 的引用,而且类型要匹配。如果你不小心传了基类引用,就会调用基类的 visit 重载,导致行为错误。我 debug 了整整一个下午才发现是这里的问题。
什么时候用?什么时候别用?
访问者模式不是银弹。我个人觉得它最适合以下场景:
- 对象结构稳定,但操作频繁变化:比如编译器、图形引擎、文档格式转换器。
- 需要对一个对象结构做很多不相关的操作:避免每个操作都污染元素类。
- 元素类不想依赖具体操作:保持元素类的纯净。
但如果你发现元素类型经常增加,那就要小心了。每加一个新元素,你都得修改所有访问者——这正好是访问者模式的痛点。我一般会问自己:未来半年内,是加操作的概率大,还是加元素类型的概率大?想清楚再决定。
嗯,访问者模式就讲到这里。它有点绕,但用熟了之后,你会发现它在某些场景下特别优雅。尤其是当你需要给一个稳定的对象结构不断添加新功能时,它能让你的代码干净很多。