17、访问者模式:双重分派、操作分离、类型安全
访问者模式,说实话,是我在嵌入式领域用得最少、但每次用都觉得「真香」的模式之一。为什么?因为它解决了一个很棘手的问题:当你的对象结构稳定,但操作却频繁变化时,你该怎么办?
我最早接触这个模式,是在做一套传感器数据采集系统的时候。当时我们有温度传感器、压力传感器、湿度传感器……每种传感器的校准算法都不一样。更麻烦的是,客户今天要加一个「统计最大值」的操作,明天又要加「生成报表」的操作。如果每次都在传感器类里加方法,那代码会变得又臭又长。访问者模式,就是来救场的。
什么是访问者模式?
说白了,访问者模式就是把「数据」和「操作」分开。数据躺在对象里不动,操作由外部的访问者来执行。这样,你想加新操作,只需要加一个新的访问者类,不用改已有的数据类。
但这里有个关键点:双重分派。什么意思?
一般的函数调用,是单分派的——根据对象的实际类型,决定调用哪个方法。但访问者模式需要根据「访问者的类型」和「被访问者的类型」两个维度来决定调用哪个方法。这就是双重分派。
我举个例子你就明白了:
// 被访问者接口
typedef struct Sensor Sensor;
struct Sensor {
void (*accept)(Sensor* self, Visitor* visitor);
};
// 温度传感器
typedef struct {
Sensor base;
float temperature;
} TempSensor;
void TempSensor_accept(Sensor* self, Visitor* visitor) {
// 关键:这里把自身类型信息传给访问者
visitor->visitTemp(visitor, (TempSensor*)self);
}
// 压力传感器
typedef struct {
Sensor base;
float pressure;
} PressureSensor;
void PressureSensor_accept(Sensor* self, Visitor* visitor) {
visitor->visitPressure(visitor, (PressureSensor*)self);
}
你看,每个被访问者在 accept 方法里,都调用了访问者中对应自己类型的 visit 函数。这就是双重分派的实现——第一次分派是 accept 根据自身类型调用不同的 visit 函数,第二次分派是 visit 函数根据访问者类型执行不同的逻辑。
操作分离:为什么这么香?
我习惯把访问者模式叫做「插件式操作」。你想想看,如果有一天你要给所有传感器加一个「导出 JSON」的功能,传统做法是什么?在每个传感器类里加一个 toJson() 方法。如果有 10 种传感器,你就得改 10 个文件。
用访问者模式呢?只需要写一个新的 JsonExporterVisitor:
typedef struct {
Visitor base;
char* output;
} JsonExporterVisitor;
void JsonExporter_visitTemp(Visitor* self, TempSensor* sensor) {
JsonExporterVisitor* visitor = (JsonExporterVisitor*)self;
// 格式化温度数据为 JSON
sprintf(visitor->output, "{"type":"temp","value":%.2f}", sensor->temperature);
}
void JsonExporter_visitPressure(Visitor* self, PressureSensor* sensor) {
JsonExporterVisitor* visitor = (JsonExporterVisitor*)self;
sprintf(visitor->output, "{"type":"pressure","value":%.2f}", sensor->pressure);
}
然后遍历所有传感器,调用它们的 accept 方法,传入这个 JSON 导出访问者。搞定。原有的传感器代码一行都不用改。
核心思想:访问者模式把「遍历结构」和「对每个元素执行的操作」分离开。遍历逻辑通常放在一个 ObjectStructure 类里,操作逻辑放在各个 Visitor 子类里。
类型安全:C 语言里的挑战
嗯,这里要注意。C 语言没有虚函数表,也没有运行时类型信息(RTTI)。所以实现访问者模式时,类型安全是个大问题。
我曾经在一个项目中,因为访问者里写错了类型转换,导致内存越界,查了两天才找到 bug。那之后我总结了一套做法:
- 用函数指针表模拟虚函数:每个 Visitor 结构体里放一组函数指针,对应每种被访问者的 visit 函数。
- 在 accept 里做类型检查:可以在被访问者结构体里加一个 type 字段,在 accept 时断言一下。
- 用宏减少重复代码:比如定义一组宏来自动生成 accept 函数。
看一个更完整的例子:
// 访问者基类
typedef struct {
void (*visitTemp)(struct Visitor* self, struct TempSensor* sensor);
void (*visitPressure)(struct Visitor* self, struct PressureSensor* sensor);
} Visitor;
// 被访问者基类
typedef struct {
int type; // 类型标识,用于运行时检查
void (*accept)(struct Sensor* self, Visitor* visitor);
} Sensor;
// 安全 accept 宏
#define SAFE_ACCEPT(sensor, visitor) \
do { \
assert((sensor)->type == TEMP_SENSOR || (sensor)->type == PRESSURE_SENSOR); \
(sensor)->accept((Sensor*)(sensor), (Visitor*)(visitor)); \
} while(0)
避坑指南:我曾经在嵌入式项目里直接用 (void*) 强转,结果因为结构体对齐问题导致访问者函数拿到的指针偏移了。后来我强制所有被访问者结构体的第一个字段都是 Sensor base,这样 (Sensor*) 转换才是安全的。
什么时候该用?什么时候别用?
我个人的经验是:
| 场景 | 推荐使用 | 原因 |
|---|---|---|
| 对象结构稳定,操作频繁变化 | ✅ 强烈推荐 | 加操作只需加 Visitor,不改已有代码 |
| 对象结构经常变化 | ❌ 别用 | 每加一种新对象,所有 Visitor 都得改 |
| 操作需要访问对象内部私有数据 | ⚠️ 谨慎 | 需要暴露接口或使用 friend(C++) |
| 嵌入式资源受限环境 | ⚠️ 考虑性能 | 函数指针调用有开销,双重分派更甚 |
说白了,访问者模式最适合的场景就是:你有一组固定的类,但你需要对这组类做各种不同的、不相关的操作。比如编译器里的 AST 遍历——语法树节点类型是固定的,但你要做语义分析、代码生成、优化……每个操作都是一个 Visitor。
SVG 结构图:访问者模式的核心逻辑
实际项目中的取舍
我记得有一次,在一个资源受限的 MCU 上,我用了访问者模式来处理不同传感器的校准。结果发现函数指针的调用开销比直接 if-else 高了 30%。后来我做了个折中:用宏生成内联的 accept 函数,编译时就把双重分派展开成直接调用。
说白了,嵌入式开发里没有银弹。访问者模式虽然优雅,但你要考虑:
- 代码空间:每个 Visitor 子类都要有一组函数指针,如果 Visitor 很多,ROM 占用会涨。
- 执行时间:双重分派意味着两次间接调用,对实时性要求高的场景要谨慎。
- 可维护性:如果团队里有人不熟悉这个模式,可能会写出奇怪的代码。
我的建议:如果你在嵌入式项目里用访问者模式,先把 Visitor 的接口定义成 inline 函数,或者用 __attribute__((always_inline)) 强制内联。这样既能享受模式的好处,又能避免性能损失。
最后说一句:访问者模式不是万能的。它最适合的场景是「对象结构稳定,操作多变」。如果你发现你的对象结构也在频繁变化,那可能要考虑别的模式了。但如果你遇到了那种「加一个操作就要改十几个类」的噩梦,访问者模式就是你的救星。