05 数据组织:实例状态、模块状态与常量

C 语言面向对象系列

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

一个模块中可能同时存在多种数据:每颗 LED 的状态、整个模块的初始化次数,以及固定的参数上限。它们的使用范围不同,存放方式也应有所区别。

多个实例共用全局变量的问题

如果把 LED 数据全部放在全局变量中:

1
2
3
4
5
int g_pin = 0;
int g_brightness = 0;
int init_count = 0;
int MAX_BRIGHTNESS = 255;
int g_debug_flag = 0;

初始化函数就会反复修改同一份数据:

1
2
3
4
5
6
7
8
9
10
11
int bad_led_init(uint8_t pin)
{
g_pin = pin;
g_brightness = 0;
init_count++;

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

return 0;
}

依次初始化两颗 LED:

1
2
3
bad_led_init(5);  /* 红灯 */
bad_led_init(6); /* 绿灯 */
bad_led_on(); /* 你以为点红灯,实际 g_pin 已经是 6 */

第二次调用将 g_pin 从 5 改为 6。后续操作只能取得最后写入的引脚号,无法区分红灯和绿灯。代码可能正常编译,日志中的变量也有值,但操作的设备已经发生变化。

我曾遇到过类似问题:同事休假期间,有人修改了 g_pin,返工后调试了三天,才发现是多实例共用数据造成的覆盖。

按使用范围组织数据

可以先分清三种情况:

数据 存放方式 示例
每个对象各有一份 结构体成员 pin、brightness、is_on
整个模块共享一份 源文件中的 static 变量 s_init_count、s_debug_flag
模块内部使用的固定值 static const 对象 MAX_BRIGHTNESS、MAX_PIN

先确定数据属于单个实例还是整个模块,再决定它是否允许修改。

将实例数据放进结构体

引脚、亮度、开关状态和初始化标志都属于某一颗 LED:

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

初始化函数通过 me 写入当前对象的成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int led_init(Led_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;
}

红灯和绿灯分别创建对象后,就能各自保存配置和状态:

1
2
3
4
5
6
7
8
Led_t red;
Led_t green;

led_init(&red, 5);
led_init(&green, 6);

led_on(&red); /* 操作 pin 5 */
led_on(&green); /* 操作 pin 6 */

调用 led_on(&red) 时,函数读取的是 red 的数据;调用 led_on(&green) 时,读取的是 green 的数据。

用 static 保存模块共享状态

初始化次数、模块调试开关等数据可能需要在整个模块内共享:

1
2
static int s_init_count = 0;
static int s_debug_flag = 0;

文件作用域的 static 变量具有内部链接。其他源文件不能通过同名的 extern 声明访问这里的变量:

1
extern int s_init_count; /* 不可行,static 变量没有外部链接 */

外部需要读取初始化次数时,可以提供查询函数:

1
2
3
4
int led_get_init_count(void)
{
return s_init_count;
}

这样既保留了模块内共享数据的能力,也将外部访问集中到明确的接口中。

用 static const 表达固定参数

下面的名称虽然使用了大写字母,定义的仍是可修改变量:

1
int MAX_BRIGHTNESS = 255;

代码仍然可以执行 MAX_BRIGHTNESS = 999;。如果值在运行期间不应改变,可以将其定义为 const。下面的示例采用 0~100 的亮度范围、0~15 的引脚范围:

1
2
static const uint8_t MAX_BRIGHTNESS = 100;
static const uint8_t MAX_PIN = 15;

其中,static 限定链接范围,const 表示对象不应被修改,uint8_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
33
34
35
36
static const uint8_t MAX_BRIGHTNESS = 100;
static const uint8_t MAX_PIN = 15;

static int s_init_count = 0;
static int s_debug_flag = 0;

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

static bool led_is_brightness_valid(uint8_t brightness)
{
return brightness <= MAX_BRIGHTNESS;
}

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

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

if (!led_is_brightness_valid(brightness)) {
return -3;
}

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

return 0;
}

这里按顺序检查对象指针、初始化状态和亮度范围。检查通过后,只更新 me 指向的对象及对应 GPIO。与前面的 GPIO 示例相同,亮度为零时关闭,非零时开启;实际 PWM 调光在后续章节讨论。

区分 static 的使用位置

本系列涉及的三种常见用法如下:

使用位置 主要作用 使用时的考虑
文件作用域的函数 使函数具有内部链接 适合内部辅助函数
文件作用域的变量 使变量具有内部链接 适合模块共享状态
函数内的局部变量 在函数调用结束后保留值 需要考虑多次调用之间的状态依赖

前两种用法主要帮助组织模块。第三种会让后一次调用依赖前一次留下的数据,测试和调试时需要考虑这种影响。

实例数据分开保存,可以避免多个外设互相覆盖;模块数据通过接口访问,可以减少外部直接修改;固定参数明确为常量,可以保留参数约束。数据的修改范围清楚后,出现异常时也更容易定位。