C语言从句柄到对象 (四) —— 接口抽象:从 Switch-Case 到通用接口

前言: 在前三期(句柄篇、封装篇、内存篇)中,我们通过引入"句柄"和"不透明指针",成功地把数据封装进了黑盒子里。 今天,我们面临一个新的挑战:如何把**逻辑(函数)**也封装进对象里?

这不仅仅是代码风格的改变,这是嵌入式**"硬件抽象层 (HAL)"** 设计的核心思想。掌握了它,你就能写出那类 "换了芯片,业务代码一行都不用改" 的高内聚代码。


一、 痛点:被 Switch-Case 支配的恐惧

假设我们正在开发一个智能家居网关,产品经理要求支持三种不同的传感器:

  1. DHT11 (单总线温湿度)

  2. SHT30 (I2C 温湿度)

  3. ADC (光敏电阻电压)

如果你还停留在"面向过程"的思维里,你的读取函数大概率会写成这样:

// 定义传感器类型

typedef enum { SENSOR_DHT11, SENSOR_SHT30, SENSOR_ADC } SensorType;

// 传感器句柄

typedef struct {

SensorType type;

// ... 其他私有数据 ...

} Sensor_t;

// 统一读取函数

float Sensor_Read(Sensor_t *s) {

// 典型的"面向过程":调用者必须知道所有底层的细节

if (s->type == SENSOR_DHT11) {

return DHT11_Read_OneWire(); // 调底层 A

}

else if (s->type == SENSOR_SHT30) {

return SHT30_Read_I2C(); // 调底层 B

}

else if (s->type == SENSOR_ADC) {

return ADC_Get_Voltage(); // 调底层 C

}

return 0.0f;

}

这种写法有两个致命问题:

  1. 耦合度极高Sensor_Read 这个上层业务函数,竟然需要包含所有底层驱动的头文件!如果底层驱动有几十个,头文件引用就会极其混乱。

  2. 违反"开闭原则" :如果你明天买了个新传感器(比如 BME280 ),你必须去修改 Sensor_Read 函数,加一个 else if改代码就有风险,这就叫"由于扩展功能而导致旧功能崩溃"。


二、 救星:把函数变成"变量"

怎么解决? 我们要让 Sensor_t 这个结构体自己 "知道" 该怎么读取数据,而不是让外面的函数去判断。

C 语言允许我们定义 函数指针。我们可以把"读取动作"存到结构体里,让对象自带方法。

2.1 定义带"动作"的结构体

// sensor.h

// 1. 前向声明

struct Sensor_t;

// 2. 定义接口原型

// 注意:第一个参数通常是对象指针自己 (this 指针)

// 这样底层驱动才能知道自己在操作哪个设备

typedef float (*Sensor_Read_Ops)(struct Sensor_t *self);

// 3. 定义类

struct Sensor_t {

char name[10];

// 【关键点】这个变量存的不是数,而是函数的地址!

Sensor_Read_Ops read;

// 私有数据指针 (配合上一期的不透明指针使用)

void *private_data;

};

2.2 具体化实例 (Instantiate)

现在,我们在初始化各种传感器时,把对应的底层函数填进去。

// main.c

// 底层驱动函数 A (符合 Sensor_Read_Ops 签名)

float DHT11_Driver(struct Sensor_t *self) {

printf("Reading DHT11 for %s...\n", self->name);

return 25.5f;

}

// 底层驱动函数 B

float SHT30_Driver(struct Sensor_t *self) {

printf("Reading SHT30 via I2C for %s...\n", self->name);

return 26.0f;

}

int main() {

// 创建传感器对象:在初始化时绑定具体的"驱动函数"

struct Sensor_t s1 = { .name = "LivingRoom", .read = DHT11_Driver };

struct Sensor_t s2 = { .name = "BedRoom", .read = SHT30_Driver };

// ...

}

三、 见证奇迹:多态调用

现在,我们的上层业务逻辑(App层)发生了翻天覆地的变化:

// 业务层代码

void App_Task_Monitor(struct Sensor_t *s) {

// 【核心变化】

// 1. 我不需要判断 s 是什么类型

// 2. 我也不需要写 switch-case

// 3. 我只管调用 s->read(),它自己知道该跑哪个驱动!

float val = s->read(s);

printf("Sensor [%s] Value: %.1f\n", s->name, val);

}

int main() {

// 定义对象

struct Sensor_t s1 = { "S1", DHT11_Driver };

struct Sensor_t s2 = { "S2", SHT30_Driver };

// 统一调用

App_Task_Monitor(&s1); // 自动调用 DHT11 代码

App_Task_Monitor(&s2); // 自动调用 SHT30 代码

}

这样做的好处是什么?

  1. 完全解耦App_Task_Monitor 再也不需要引用 dht11.hsht30.h 了。它只认识标准的 Sensor_t 接口。

  2. 极致扩展 :如果明天来了个 BME280 ,你只需要定义一个新的 struct Sensor_t s3 = {..., BME280_Driver}App_Task 函数一行代码都不用改!

这就是 多态 (Polymorphism) ------ 同一个接口 (s->read()),在不同对象上表现出不同的行为。


四、 现实中的尴尬:RAM 的隐忧

细心的嵌入式工程师可能已经发现了一个隐患。

上面的写法在对象很少时没问题。但如果你的系统比较复杂,Sensor_t 结构体里不止一个函数,而是有一组函数(接口表):

struct Sensor_t {

// ... 数据 ...

void (*init)(void);

float (*read)(void);

void (*write)(float);

void (*sleep)(void);

void (*reset)(void);

};

如果你有 100 个传感器对象:

  • RAM 消耗 = 100 个对象 × 5 个指针 × 4 字节 = 2000 字节!

  • 关键是:对于所有的 DHT11 对象来说,这 5 个函数指针的值是完全一样的(都指向 DHT11_xxx 函数)。我们为什么要重复存 100 遍一模一样的数据?

怎么优化? 能不能把这组函数指针提取出来,存到 Flash 里(只存一份),每个对象只存一个指向 Flash 的"索引指针"?

这就是 C++ 编译器在幕后为你做的 "虚函数表 (V-Table)" 技术。 下一期,我们将手动实现它,让你的 C 代码既具备面向对象的灵活性,又拥有嵌入式级别的 RAM 优化。

/*******************************************
* Description:
* 本文为作者《嵌入式开发基础与工程实践》系列文之一。
* 关注我即可订阅后续内容更新,采用异步推送机制。
* 转发本文可视为广播分发,有助于信息传播至更多节点。
*******************************************/

相关推荐
csbysj20202 小时前
WebPages 数据库:构建现代网页管理的基石
开发语言
lzhdim2 小时前
C#性能优化:从入门到入土!这10个隐藏技巧让你的代码快如闪电
开发语言·性能优化·c#
沐知全栈开发2 小时前
C 标准库 - `<stdarg.h>`
开发语言
两个人的幸福online2 小时前
给cocos 3.8 消息控制器
开发语言·javascript·ecmascript
廋到被风吹走2 小时前
【JAVA】【JDK】java8版本之后各个版本调整
java·开发语言
悟能不能悟2 小时前
如何处理java.time包类序列化问题,跨版本反序列化 Class对象可能抛出 InvalidClassException
java·开发语言
xxxxxxllllllshi2 小时前
深入解析单例模式:从原理到实战,掌握Java面试高频考点
java·开发语言·单例模式·面试
=PNZ=BeijingL2 小时前
SprintBoot +Screw+PostgreSQL生成数据库文档时空指针问题
开发语言·c#
L-岁月染过的梦2 小时前
前端使用JS实现端口探活
开发语言·前端·javascript