11 接口约定:必选操作、可选操作与默认实现

C 语言面向对象系列

  1. 总纲:C 语言面向对象设计概览
  2. 封装:结构体与多实例接口
  3. 信息隐藏:公共接口与内部实现
  4. 模块组织:命名约定与对象生命周期
  5. 数据组织:实例状态、模块状态与常量
  6. HAL 实例:STM32 外设接口中的封装设计
  7. 结构体嵌套:公共数据与行为的复用
  8. 函数指针:可替换行为与回调
  9. 操作表:使用 ops 组织对象接口
  10. 多态:统一接口、行为分发与类型转换
  11. 接口约定:必选操作、可选操作与默认实现(当前篇)
  12. 框架整合:应用、驱动与平台的分层
  13. 工程扩展:自动注册与 Linux 内核中的对象组织

操作表中的函数指针可能为 NULL。设计接口时,需要先约定哪些操作必须提供、哪些允许缺省,以及缺省时如何处理。

本节在前面的 ops 基础上加入这些约定,让调用方和实现方遵循同一规则。

明确空函数指针的含义

例如,这份 PWM 操作表没有提供 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,
};

如果统一入口直接执行 return me->ops->deinit(me);,就会调用空函数指针。这属于未定义行为,不能依靠它返回错误,程序也可能发生异常。

NULL 是否允许出现,需要由接口约定决定。调用入口必须据此检查、返回错误,或执行默认操作。

绑定操作表时检查必选操作

可以先为 LED 接口作出如下约定:

操作 要求 说明
on 必须提供 支持点亮
off 必须提供 支持关闭
set_brightness 可以缺省 普通 GPIO LED 可能没有实际调光能力
deinit 由资源管理方式决定 明确是否需要由该接口释放资源

必选操作可以在绑定操作表时统一检查:

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;
}

led_ops_is_valid() 检查操作表以及 on、off。led_base_bind_ops() 只在检查通过后记录指针。调用方需要处理绑定失败,避免继续使用未完成绑定的对象。

如果框架规定 deinit 必须实现,就还需要将它纳入检查。检查内容应与文档中的要求一致。

为可选操作提供默认实现

普通 GPIO LED 不能通过占空比调节亮度,但可以约定一种简化行为:亮度为 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);
}

统一入口优先使用具体设备提供的函数;没有提供时,再调用默认函数:

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);
}

这样,采用同一种默认行为的设备无需重复实现函数。这个默认行为只提供开关控制,调用方仍需要知道它与实际调光的区别。

将不支持的操作报告给调用方

有些操作没有合适的默认实现,就应返回错误。例如,设备不一定支持 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);
}

这里依次检查设备、操作表和函数指针,缺少任何一项就返回错误。大型 C 框架中的可选操作也需要类似的调用约定;具体哪些函数可选,应查对应接口文档。

与 C++ 接口机制的对照

C++ 可以用抽象类和虚函数描述类似要求:

设计用途 C++ 写法 本系列的 C 写法
要求具体类型提供操作 纯虚函数,例如 virtual void on() = 0 将对应 ops 成员列为必选并检查
允许不同类型分别实现 虚函数,例如 virtual void on() 为各类型绑定不同函数指针
共用默认行为 基类提供实现 在统一入口中调用默认函数
检查接口是否满足要求 编译器根据类定义检查 通过 ops_is_valid() 等函数检查

两者的检查方式不同。C 中声明一个函数指针成员,并不意味着编译器会要求所有操作表都填写这个成员,因此需要代码和文档共同表达要求。

将接口约定落实到代码

设计操作表时,可以逐项确认:

内容 在代码中的安排
必须支持的操作 初始化或绑定时检查
允许缺省的操作 调用前判断
不支持操作时的结果 返回统一约定的错误码
缺省时的行为 集中实现默认函数
资源的释放责任 明确 deinit 的要求和调用时机

操作表提供了调用入口,接口约定则说明这些入口何时可用、失败时返回什么,以及调用方需要承担哪些责任。