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。那之后我总结了一套做法:

  1. 用函数指针表模拟虚函数:每个 Visitor 结构体里放一组函数指针,对应每种被访问者的 visit 函数。
  2. 在 accept 里做类型检查:可以在被访问者结构体里加一个 type 字段,在 accept 时断言一下。
  3. 用宏减少重复代码:比如定义一组宏来自动生成 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 结构图:访问者模式的核心逻辑

访问者模式核心结构 Sensor (被访问者接口) accept(Visitor) TempSensor PressureSensor Visitor (访问者接口) visitTemp() / visitPressure() Calibrator JsonExporter 双重分派 双重分派流程 1. 客户端调用 sensor->accept(visitor) 2. sensor 根据自身类型,调用 visitor->visitTemp() 或 visitPressure() 3. visitor 根据自身类型,执行对应的操作逻辑

实际项目中的取舍

我记得有一次,在一个资源受限的 MCU 上,我用了访问者模式来处理不同传感器的校准。结果发现函数指针的调用开销比直接 if-else 高了 30%。后来我做了个折中:用宏生成内联的 accept 函数,编译时就把双重分派展开成直接调用。

说白了,嵌入式开发里没有银弹。访问者模式虽然优雅,但你要考虑:

  • 代码空间:每个 Visitor 子类都要有一组函数指针,如果 Visitor 很多,ROM 占用会涨。
  • 执行时间:双重分派意味着两次间接调用,对实时性要求高的场景要谨慎。
  • 可维护性:如果团队里有人不熟悉这个模式,可能会写出奇怪的代码。

我的建议:如果你在嵌入式项目里用访问者模式,先把 Visitor 的接口定义成 inline 函数,或者用 __attribute__((always_inline)) 强制内联。这样既能享受模式的好处,又能避免性能损失。

最后说一句:访问者模式不是万能的。它最适合的场景是「对象结构稳定,操作多变」。如果你发现你的对象结构也在频繁变化,那可能要考虑别的模式了。但如果你遇到了那种「加一个操作就要改十几个类」的噩梦,访问者模式就是你的救星。