11 接口设计:ops 合同与默认实现

C 语言 OOP 系列导航

本系列面向会 C 和单片机基础、刚进入企业项目的同学,从 struct 封装一路讲到 ops、多态和类 Linux 驱动框架。

  1. C 语言面向对象设计路线
  2. 封装:struct 数据与 me 指针
  3. 信息隐藏:static 与头文件边界
  4. 类的组织:前缀、init/deinit 与生命周期
  5. 数据归位:struct 成员与 static 变量
  6. HAL 映射:工业库的封装结构
  7. 继承:struct 嵌套复用公共字段
  8. 函数指针:把行为作为参数
  9. ops/vtable:行为表与对象绑定
  10. 多态与转型:base 指针与 dispatch
  11. 接口设计:ops 合同与默认实现 ⇐ 当前位置
  12. 完整框架:抽象层与硬件解耦
  13. 终章:自动注册与 Linux 内核 OOP 结构

ops 表把 C 语言带到了虚函数层级,但它也带来一个新风险:函数指针可能是 NULL

例如操作表定义如下:

1
2
3
4
5
6
typedef struct {
int (*on)(LedBase_t *me);
int (*off)(LedBase_t *me);
int (*set_brightness)(LedBase_t *me, uint8_t brightness);
int (*deinit)(LedBase_t *me);
} LedOps_t;

某个具体实现可能没有提供 deinit

1
2
3
4
5
6
static const LedOps_t pwm_led_ops = {
.on = pwm_led_on,
.off = pwm_led_off,
.set_brightness = pwm_led_set_brightness,
.deinit = NULL,
};

如果 dispatch 直接调用:

1
return me->ops->deinit(me);

就会空指针解引用。

一句话先懂:ops 不是函数指针杂货铺,它是一份接口合同,必须明确哪些能力必选、哪些能力可选、不支持时怎么办。

先看问题:槽位缺失不能靠调用方猜

问题 后果
ops 槽位没实现 空指针调用
上层不知道可选 / 必选 接口合同不清楚
每个地方自己检查 错误处理不一致
没有默认实现 简单设备也要写重复函数

接口设计的目标不是“把函数都塞进表里”,而是让调用方知道什么一定能用,什么可能不可用。

先定义接口合同

设计 ops 时,先约定每个槽位的性质:

函数 是否必须 说明
on 必须 LED 至少要能点亮
off 必须 LED 至少要能关闭
set_brightness 可选 普通 GPIO LED 可能不支持真实调光
deinit 可选或必须 看框架是否负责资源释放

这份约定不能只写在脑子里,要体现在代码检查里。

必选函数在绑定时检查

dispatch 做 NULL 检查可以避免崩溃,但更好的做法是对象绑定 ops 时就拒绝不合规实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
static bool led_ops_is_valid(const LedOps_t *ops)
{
if (ops == NULL) {
return false;
}

if (ops->on == NULL || ops->off == NULL) {
return false;
}

return true;
}

int led_base_bind_ops(LedBase_t *me, const LedOps_t *ops)
{
if (me == NULL || !led_ops_is_valid(ops)) {
return -1;
}

me->ops = ops;
return 0;
}

这样 on/off 这种必选能力从一开始就被保证了。

可选函数在 dispatch 中处理

deinit 如果是可选,就要在统一入口里处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int led_deinit(LedBase_t *me)
{
if (me == NULL) {
return -1;
}

if (me->ops == NULL) {
return -2;
}

if (me->ops->deinit == NULL) {
return -3;
}

return me->ops->deinit(me);
}

这样调用方得到的是明确错误码,而不是系统直接跑飞。

默认实现承接常见可选能力

有些可选能力可以给默认实现。例如普通 GPIO LED 不支持真实亮度,但可以把 brightness == 0 理解为关灯,其他值理解为开灯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int led_default_set_brightness(LedBase_t *me, uint8_t brightness)
{
if (me == NULL) {
return -1;
}

me->brightness = brightness;

if (brightness == 0) {
return led_off(me);
}

return led_on(me);
}

dispatch 中优先调用具体实现,没有实现就走默认实现:

1
2
3
4
5
6
7
8
9
10
11
12
int led_set_brightness(LedBase_t *me, uint8_t brightness)
{
if (me == NULL || me->ops == NULL) {
return -1;
}

if (me->ops->set_brightness != NULL) {
return me->ops->set_brightness(me, brightness);
}

return led_default_set_brightness(me, brightness);
}

这能减少简单设备的重复模板代码。

Linux 风格接口为什么常允许 NULL

大型 C 框架里,很多 ops 槽位都是可选的:

1
2
3
4
5
6
7
typedef struct {
int (*open)(void *obj);
int (*read)(void *obj, void *buf, int len);
int (*write)(void *obj, const void *buf, int len);
int (*ioctl)(void *obj, int cmd, void *arg);
int (*release)(void *obj);
} DeviceOps_t;

不是所有设备都支持 ioctl,所以调用前必须检查:

1
2
3
4
5
6
7
8
int device_ioctl(Device_t *dev, int cmd, void *arg)
{
if (dev == NULL || dev->ops == NULL || dev->ops->ioctl == NULL) {
return -1;
}

return dev->ops->ioctl(dev, cmd, arg);
}

允许 NULL 没问题,前提是接口合同明确,并且统一入口负责处理。

新人常见误区

误区 更稳的做法
所有槽位都填满才专业 能默认的默认,不能支持的明确返回错误
每个调用点自己判空 集中在 dispatch 中处理
NULL 表示没关系 NULL 必须有明确语义
ops 表越大越好 只放稳定、真正属于对象的能力

纯虚、虚函数与默认实现

概念 C++ C 语言
纯虚函数 virtual void on() = 0 ops 槽位必须非 NULL
虚函数 virtual void on() ops 槽位可被具体实现覆盖
默认实现 基类提供实现 dispatch 里走默认函数
接口合同 抽象类声明 ops_is_valid() 检查

C 语言不会在编译期校验“纯虚函数是否被实现”,所以这份职责要由初始化代码承担。

嵌入式项目意义

驱动接口必须回答这些问题:

设计问题 应该明确
哪些操作必须支持 初始化时检查
哪些操作可以不支持 dispatch 返回错误或默认实现
不支持时返回什么 统一错误码
默认行为是什么 写成明确函数
谁负责释放资源 deinit 合同

否则 ops 会从解耦工具变成空指针雷区。

总结

ops 是接口合同,不是函数指针杂货铺;必选函数初始化时检查,可选函数调用前判断,能默认实现就集中默认实现。