07 结构体嵌套:公共数据与行为的复用

C 语言面向对象系列

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

前面的封装让每个 LED 拥有独立数据。接下来考虑多种 LED 实现:GPIO LED、PWM LED、呼吸灯和 RGB LED 都需要记录状态,但各自还需要不同的配置。

本节通过结构体嵌套提取公共部分。这种写法可以实现类似继承的数据复用,C 代码仍然需要显式访问内嵌成员、调用公共函数。

找出重复的字段

分别定义三种 LED 时,可能会重复填写公共成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
} GpioLed_t;

typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
uint8_t pwm_channel;
} PwmLed_t;

typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
uint16_t period_ms;
} BreathingLed_t;

这三个类型的前四个字段相同。如果要增加公共字段 name,就需要修改三个定义。初始化标志检查等公共操作,也容易在多个函数中重复。

提取基础结构体

将共同字段放进 LedBase_t:

1
2
3
4
5
6
typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
bool initialized;
} LedBase_t;

具体类型再将它作为成员,并添加各自需要的数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct {
LedBase_t base;
} GpioLed_t;

typedef struct {
LedBase_t base;
uint8_t pwm_channel;
} PwmLed_t;

typedef struct {
LedBase_t base;
uint16_t period_ms;
bool increasing;
} BreathingLed_t;

base 保存公共部分,pwm_channel、period_ms 和 increasing 保存具体实现所需的数据。

写法 含义
LedBase_t base; 在对象中保存公共数据
gpio_led.base.pin 访问公共成员
led_base_on(&obj->base) 将公共部分交给基础函数处理
具体结构体新增成员 保存该类型独有的数据

公共函数接收 LedBase_t *

公共函数只需要接收基础部分的指针:

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
int led_base_init(LedBase_t *me, uint8_t pin)
{
if (me == NULL) {
return -1;
}

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

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

return 0;
}

int led_base_off(LedBase_t *me)
{
if (me == NULL) {
return -1;
}

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

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

return 0;
}

具体类型初始化时,先调用公共初始化函数,再处理自己的配置:

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
int gpio_led_init(GpioLed_t *me, uint8_t pin)
{
if (me == NULL) {
return -1;
}

return led_base_init(&me->base, pin);
}

int pwm_led_init(PwmLed_t *me, uint8_t pin, uint8_t channel)
{
if (me == NULL) {
return -1;
}

int ret = led_base_init(&me->base, pin);
if (ret != 0) {
return ret;
}

me->pwm_channel = channel;
pwm_init(channel);

return 0;
}

GPIO LED 直接复用基础初始化。PWM LED 在基础初始化成功后,再保存通道并调用 pwm_init()。

这里仍沿用前面的简化实现,基础函数中还包含 GPIO 操作。结构体嵌套已经复用了数据和部分逻辑,但要让不同硬件共用同一操作接口,还需要后面的函数指针和操作表。

基础成员的布局与取址

本系列将 base 放在具体结构体的第一个成员:

1
2
3
4
typedef struct {
LedBase_t base;
uint8_t pwm_channel;
} PwmLed_t;

因此对象起始位置就是基础成员。取得基础指针时,直接使用 LedBase_t *base = &pwm_led.base; 即可。

需要区分两个概念:把 base 放在首位是一种布局约定,而 &pwm_led.base 是对成员取地址。即使成员不在首位,也可以这样取址。后面的 container_of 会根据成员偏移计算完整对象的地址。

复用数据后仍需处理行为差异

不同 LED 的点亮方式并不相同:GPIO LED 设置电平,PWM LED 设置占空比,RGB LED 可能控制三个通道,呼吸灯还需要定时改变亮度。

如果基础函数固定调用 gpio_write(),它就不能直接完成其他硬件的点亮操作。本节的结构体嵌套解决了公共数据和已有公共逻辑的复用;各类型的行为差异会在后续章节通过函数指针和 ops 处理。

与 C++ 继承的对照

C++ 可以用基类和派生类组织这些数据:

1
2
3
4
5
6
7
8
9
10
11
12
// C++
class LedBase {
protected:
int pin;
int brightness;
bool is_on;
};

class PwmLed : public LedBase {
private:
int pwm_channel;
};

C 则显式嵌入基础结构体:

1
2
3
4
5
6
7
8
9
10
11
// C
typedef struct {
int pin;
int brightness;
bool is_on;
} LedBase_t;

typedef struct {
LedBase_t base;
int pwm_channel;
} PwmLed_t;

两种写法在数据组织上的对应如下:

C++ 本例中的 C 写法
基类 Base 基础结构体 LedBase_t
class Derived : public Base 在具体类型中嵌入 LedBase_t base
访问基类数据 通过 base.xxx 访问成员
调用基类操作 显式调用 led_base_xxx(&obj->base)
派生类自身的数据 具体结构体新增的成员

结构体嵌套不会自动获得 C++ 的访问控制和类型转换规则。本系列借用“基类”“派生类型”等术语描述公共部分与具体实现,实际操作仍以 C 的成员访问和函数调用为准。

类似的组织方式也可用于存储设备(Flash、EEPROM、SD 卡)、通信接口(UART、SPI、I2C)或传感器(温度、湿度、IMU)。同类设备确实需要共用状态、配置或错误处理时,可以考虑提取公共部分。

下一篇先介绍单个函数指针,说明如何在保留对象参数的同时替换要执行的操作。