04 模块组织:命名约定与对象生命周期

C 语言面向对象系列

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

前面已经有了 LED 类型和操作接口。本节把这些内容组织成一个模块:用统一前缀命名函数,通过 init/deinit 管理对象从初始化、使用到释放的过程。这个过程称为对象的生命周期。

用函数前缀区分模块

假设 LED 模块的接口命名如下:

1
2
3
int init(void);
int on(void);
int off(void);

电机模块也采用相同的命名方式:

1
2
3
int init(void);
int start(void);
int stop(void);

如果两个模块都定义了具有外部链接的 init(),链接时会发生重名冲突。即使没有冲突,单看 init() 也很难判断它属于哪个模块。

可以给 LED 接口统一添加 led_ 前缀:

1
2
3
4
5
int led_init(Led_t *me, uint8_t pin);
int led_deinit(Led_t *me);
int led_on(Led_t *me);
int led_off(Led_t *me);
int led_set_brightness(Led_t *me, uint8_t brightness);

电机接口使用 motor_ 前缀:

1
2
3
4
5
int motor_init(Motor_t *me, uint8_t pin);
int motor_deinit(Motor_t *me);
int motor_start(Motor_t *me);
int motor_stop(Motor_t *me);
int motor_set_speed(Motor_t *me, uint8_t speed);

同样,按键和串口模块可以分别使用 button_、uart_。前缀明确了函数所属的模块,也让调用代码更容易阅读。它是一种命名约定,不会创建 C++ 类或命名空间那样的作用域。

初始化对象和硬件

声明一个 Led_t 变量后,还需要设置成员并配置硬件。下面的 led_init() 依次检查参数、初始化 GPIO、填写默认状态,最后让 LED 保持关闭:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#define LED_PIN_MAX 15

typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
} Led_t;

static bool led_is_pin_valid(uint8_t pin)
{
return pin <= LED_PIN_MAX;
}

int led_init(Led_t *me, uint8_t pin)
{
if (me == NULL) {
return -1;
}

if (!led_is_pin_valid(pin)) {
return -2;
}

gpio_init(pin);

me->pin = pin;
me->brightness = 0;
me->is_on = false;
me->initialized = true;

gpio_write(pin, false);

return 0;
}

成功返回后,pin、brightness、is_on 和 initialized 都有了明确的值。调用方应检查返回值,再使用对象。

这个函数承担了类似构造函数的初始化工作,不过 C 不会在声明对象时自动调用它。

使用结束后释放资源

led_deinit() 负责关闭 LED、释放 GPIO 配置,并清除初始化标志:

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

if (!me->initialized) {
return 0;
}

me->is_on = false;
me->brightness = 0;
gpio_write(me->pin, false);
gpio_deinit(me->pin);

me->initialized = false;

return 0;
}

这与析构函数都有清理资源的用途。C++ 的普通局部对象离开作用域时会自动调用析构函数;这里的 C 接口需要调用方显式调用。

一次完整使用过程如下:

1
2
3
4
5
6
7
8
9
10
Led_t led;

if (led_init(&led, 5) != 0) {
return -1;
}

led_on(&led);
led_set_brightness(&led, 80);

led_deinit(&led);

对象初始化成功后才能使用,使用结束后再反初始化。GPIO、电机、按键等模块都可以采用类似的调用顺序。

在操作接口中检查状态

公开接口可以先检查对象指针,再检查初始化标志:

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

if (!me->initialized) {
return -2;
}

me->is_on = true;
gpio_write(me->pin, true);

return 0;
}

initialized 为 false 时,函数返回错误,避免继续操作引脚。这个检查的前提是标志本身已有确定的值。对于尚未初始化的局部变量,不能依靠读取其中的 initialized 判断状态;可以在创建时先零初始化,再调用 led_init()。

这些检查分别处理不同问题:

检查 作用
me == NULL 避免访问空指针
pin 范围 拒绝非法引脚参数
initialized 检查对象是否处于可用状态
返回错误码 让调用方决定如何处理失败

外设操作也有顺序要求。例如,GPIO 需要先配置,UART 发送前需要开启相应时钟,PWM 输出前需要完成配置。初始化接口应集中处理这些准备工作。

一个模块中的职责分工

可以按下面的方式组织 LED 模块:

内容 职责
led.h 声明公开类型和接口
led.c 实现接口和内部逻辑
Led_t 保存一个对象的数据
led_init() 设置初始状态并配置硬件
led_deinit() 释放资源并结束使用
led_on() 等操作函数 通过对象指针执行操作
static 辅助函数 供当前源文件内部使用
led_ 前缀 标明接口所属模块

这种组织方式把对象数据、操作接口和生命周期放在一起。阅读模块时,可以先看头文件了解用法,再进入源文件查看实现。