访问者模式常用于分离数据结构与操作逻辑,但经典实现依赖继承与虚函数,在性能敏感场景中会成为瓶颈。通过模板化手段把分派动作提前到编译期,可以在不损失扩展性的前提下消除运行时开销。下面先展示基础结构,再逐步推导出完整实现。

传统访问者模式的局限
在标准访问者模式中,每个元素类都需要定义一个 accept 虚函数,该函数接收基类访问者指针并回调对应的 visit 方法。这种结构在新增元素类型时,必须修改所有已有访问者基类,违背开闭原则;同时每次调用都经过虚表查找,对高频遍历并不友好。
另一个隐性问题是,如果某个具体访问者漏写了某个派生类的 visit 重载,编译器不会报错,错误会推迟到运行期才暴露。对于需要严格覆盖所有类型的系统,例如编译器抽象语法树处理,这种运行时才发现的缺失非常危险。
模板化访问者的核心设计
模板化访问者的关键是将 accept 写成函数模板,让元素在编译期就知道访问者类型,从而直接调用正确的重载。我们不再依赖基类指针,而是利用泛型推导,把分派转变为静态多态。
下面给出一个最小可用的框架。元素基类提供模板 accept,派生类通过 CRTP 或直接使用模板方法把自身传给访问者。访问者本身则是一组重载 operator() 的结构体,编译器会根据传入对象类型选择最匹配的重载。
#include <iostream>
struct Number {
int value;
template <typename Visitor>
void accept(Visitor& v) {
v(*this);
}
};
struct Text {
std::string content;
template <typename Visitor>
void accept(Visitor& v) {
v(*this);
}
};
struct PrintVisitor {
void operator()(const Number& n) const {
std::cout << "Number: " << n.value << std::endl;
}
void operator()(const Text& t) const {
std::cout << "Text: " << t.content << std::endl;
}
};
int main() {
Number n{42};
Text t{"hello"};
PrintVisitor pv;
n.accept(pv);
t.accept(pv);
return 0;
}
上述代码中,accept 是模板函数,因此 n.accept(pv) 在实例化时就确定了调用 PrintVisitor 的 Number 重载。没有虚函数参与,也没有基类耦合。如果访问者缺少对应重载,编译会直接失败,把错误挡在编译期。
结合 std::variant 处理异构集合
当我们需要把不同类型的元素放入同一个容器并统一遍历时,可以使用 std::variant 配合 std::visit 完成编译期分派。这样可以避免为每种元素单独写容器,也保持类型安全。
下面的示例把 Number 与 Text 放进 variant 数组,用同一个访问者处理。std::visit 在编译期展开所有替代类型,效果与我们手写的模板 accept 一致,但代码更简洁。
#include <iostream>
#include <variant>
#include <vector>
struct Number { int value; };
struct Text { std::string content; };
using Element = std::variant<Number, Text>;
struct PrintVisitor {
void operator()(const Number& n) const {
std::cout << "Number: " << n.value << std::endl;
}
void operator()(const Text& t) const {
std::cout << "Text: " << t.content << std::endl;
}
};
int main() {
std::vector<Element> items = {Number{1}, Text{"a"}, Number{2}};
PrintVisitor pv;
for (const auto& item : items) {
std::visit(pv, item);
}
return 0;
}
这种写法适合元素类型固定但操作多变的场景。若未来加入新类型,只需在 variant 和访问者中各加一处,编译器会强制要求访问者覆盖新类型,避免遗漏。
编译期分派的优势与适用边界
模板化访问者把分派逻辑交给编译器,生成的机器码通常等价于直接函数调用,没有分支预测失败或虚表跳转。在性能剖析中,这类访问者常比传统虚函数版本快数倍,尤其当遍历规模达到百万级节点时差异明显。
不过它并非万能。由于类型必须在编译期确定,无法像运行时访问者那样动态加载未知类型。如果系统需要插件式扩展元素类型,仍要回到虚函数或反射机制。模板化方案更适合类型封闭、操作频繁变动的内部模块,比如序列化内核、AST 解释器前端。
实践中的扩展技巧
可以通过给访问者增加返回类型模板,让不同 visit 调用返回统一结果,从而支持求值类操作。也可以借助 if constexpr 在同一个 operator() 里根据类型标签做细分处理,减少重载数量。
下例展示带返回值的访问者,计算元素大小的通用接口。编译器依旧在实例化阶段确定分支,没有运行时判断。
#include <iostream>
struct Number { int value; };
struct Text { std::string content; };
template <typename T>
struct SizeVisitor {
std::size_t operator()(const Number& n) const {
return sizeof(n.value);
}
std::size_t operator()(const Text& t) const {
return t.content.size();
}
};
int main() {
Number n{10};
Text t{"sample"};
SizeVisitor<void> sv;
std::cout << "Number size: " << sv(n) << std::endl;
std::cout << "Text size: " << sv(t) << std::endl;
return 0;
}
把模板参数与重载结合,还能实现访问者之间的组合复用。例如把日志访问者与转换访问者通过模板继承拼装,既保持编译期分派,又避免重复写类型匹配代码。