02 封装:结构体与多实例接口

C 语言面向对象系列

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

本节从三颗 LED 开始,将重复的控制函数整理成一组可复用接口。需要完成两件事:用结构体保存每颗 LED 的数据,让函数通过指针确定本次操作的对象。

分别编写设备函数会产生重复

按“设备名加动作”命名时,点亮三颗 LED 的代码可能如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void red_led_on(void)
{
gpio_write(5, true);
}

void green_led_on(void)
{
gpio_write(6, true);
}

void blue_led_on(void)
{
gpio_write(7, true);
}

三个函数的操作相同,只有引脚号不同。继续增加关闭、翻转、设置亮度或闪烁等操作时,每颗灯又需要一组函数:

1
2
3
4
5
6
7
void red_led_off(void);
void green_led_off(void);
void blue_led_off(void);

void red_led_toggle(void);
void green_led_toggle(void);
void blue_led_toggle(void);

设备和操作越多,需要维护的副本就越多。引脚号固定在函数内部,换引脚需要修改实现;亮度、开关状态等数据也缺少统一的存放位置。上层还需要知道每颗灯对应哪个接口。

可以把设备之间的差别放进数据中,让相同的逻辑共用一份函数。

用结构体保存一颗 LED 的数据

本例用引脚号、亮度和开关状态描述 LED:

1
2
3
4
5
6
7
8
#include <stdbool.h>
#include <stdint.h>

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

Led_t 定义了数据的结构。红灯、绿灯、蓝灯分别创建一个变量,每个变量就是一个实例:

1
2
3
Led_t red_led;
Led_t green_led;
Led_t blue_led;

每个实例各自保存配置和运行状态。只传一个 pin 参数虽然能指定引脚,但无法同时管理这些相关数据;将它们放在同一个对象中,函数就能通过统一入口访问。

通过 me 指定操作对象

函数接收一个 Led_t * 参数,用它访问当前 LED:

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

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

return 0;
}

me 是普通的参数名,也可以写成 self。本系列约定把对象指针放在第一个参数,便于识别函数操作的对象。

调用形式如下,实际执行前需要先初始化这些对象:

1
2
3
led_on(&red_led);
led_on(&green_led);
led_on(&blue_led);

&red_led 取得红灯对象的地址。函数接收这个地址后,me->pin 读取红灯的引脚,me->is_on 修改红灯的状态。传入绿灯地址时,相同代码就会访问绿灯的数据。

初始化对象后再调用操作接口

声明变量后,还需要填入配置和默认状态,并完成 GPIO 配置:

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

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

gpio_init(pin);
gpio_write(pin, false);

return 0;
}

完整的调用顺序是先初始化,再执行操作:

1
2
3
4
5
6
7
8
9
10
11
Led_t red_led;
Led_t green_led;
Led_t blue_led;

led_init(&red_led, 5);
led_init(&green_led, 6);
led_init(&blue_led, 7);

led_on(&red_led);
led_on(&green_led);
led_off(&blue_led);

这里为三颗 LED 分别指定引脚 5、6、7。led_off() 可以采用与 led_on() 相同的组织方式,关闭对象对应的 LED。

接口的变化如下:

原来的调用 使用对象后的调用
red_led_on() led_on(&red_led)
green_led_on() led_on(&green_led)
blue_led_on() led_on(&blue_led)

操作由函数决定,设备由参数决定。之后修改点亮逻辑时,只需要维护同一份 led_on()。

为对象增加亮度设置接口

增加操作时,仍然使用同一个对象指针。下面的接口检查亮度范围,并把数值保存到对象中:

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

if (brightness > 100) {
return -2;
}

me->brightness = brightness;

if (brightness == 0) {
me->is_on = false;
gpio_write(me->pin, false);
} else {
me->is_on = true;
gpio_write(me->pin, true);
}

return 0;
}

调用方仍然传入对象和参数:

1
2
3
led_set_brightness(&red_led, 80);
led_set_brightness(&green_led, 30);
led_set_brightness(&blue_led, 0);

这段 GPIO 示例只区分开关:亮度为 0 时关闭,大于 0 时开启。80 和 30 会分别保存在对象中,但这段代码不会产生不同的实际亮度。后面引入 PWM 实现时,再通过占空比完成调光。

新增这种操作,只需要为 Led_t 编写一个函数,各个 LED 实例都可以使用。

与 C++ 成员函数的对应关系

同样的数据和操作,在 C++ 中可以组织成类:

1
2
3
4
5
6
7
8
9
10
11
class Led {
public:
Led(int pin);
void on();
void off();

private:
int pin;
int brightness;
bool is_on;
};

可以对照下面几种写法理解对象参数:

C 语言 C++
Led_t class Led 中的数据组织
Led_t *me 成员函数中的 this
led_init(&led, pin) 通过 Led led(pin) 初始化对象
led_on(&led) led.on()
me->pin this->pin

在这里,C 调用显式传入对象指针,C++ 的非静态成员函数通过 this 访问当前对象。初始化、访问权限等机制仍有区别,后面会分别说明。

将同一写法用于多个设备

假设项目有 10 个 LED、5 个按键和 3 个电机,可以为每类设备定义类型和接口,再按设备数量创建实例。单个全局变量虽然能保存一台设备的状态,但多个实例需要各自的数据。

例如,按前面的方式声明并初始化 red、green、blue 后,可以复用同一个接口:

1
2
3
4
// 同一份 led_on,三个不同的对象
led_on(&red);
led_on(&green);
led_on(&blue);

这样既能共享操作逻辑,也能分别保存设备状态。接下来需要进一步约定外部代码如何访问这些数据,避免绕过接口直接修改成员。