03 信息隐藏:公共接口与内部实现

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
typedef struct {
uint8_t pin;
uint8_t brightness;
bool is_on;
} Led_t;

点亮 LED 时,调用接口:

1
led_on(&red_led);

如果改为直接给成员赋值:

1
red_led.is_on = true;

这条语句只修改 is_on,并没有执行对应的 GPIO 操作:

1
gpio_write(red_led.pin, true);

假设 LED 原本处于关闭状态,此时 is_on 已经是 true,LED 却仍然没有点亮。后续代码如果根据这个字段判断状态,就可能得到错误结果,日志记录也会与硬件表现不一致。

因此,修改状态的接口需要同时处理对应的硬件操作。信息隐藏的作用,就是减少外部代码绕过这些接口的机会。

头文件声明接口,源文件组织实现

一个 C 模块通常由头文件和源文件组成:

文件 主要内容
.h 供外部使用的类型和函数声明
.c 函数实现、内部辅助函数和模块状态

LED 模块可以先提供以下头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef LED_H
#define LED_H

#include <stdbool.h>
#include <stdint.h>

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

int led_init(Led_t *me, uint8_t pin);
int led_on(Led_t *me);
int led_off(Led_t *me);
int led_set_brightness(Led_t *me, uint8_t brightness);
int led_get_state(const Led_t *me, bool *is_on, uint8_t *brightness);

#endif

本例仍在头文件中公开结构体定义,因此调用方在语法上可以访问成员。这里先约定通过 led_xxx() 接口读写状态;需要更严格的信息隐藏时,可以使用不透明结构体,将完整定义放在源文件中。

用 static 限定内部辅助函数

如果多个接口各自操作 GPIO,代码会出现重复:

1
2
3
4
5
6
7
8
9
10
11
12
13
int led_on(Led_t *me)
{
me->is_on = true;
gpio_write(me->pin, true);
return 0;
}

int led_off(Led_t *me)
{
me->is_on = false;
gpio_write(me->pin, false);
return 0;
}

硬件改成低电平点亮时,这些位置都需要修改。可以先把硬件更新集中到一个辅助函数中:

1
2
3
4
static void led_update_hardware(Led_t *me)
{
gpio_write(me->pin, me->is_on);
}

公开接口负责修改状态,再调用这个函数更新硬件:

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

me->is_on = true;
led_update_hardware(me);

return 0;
}

int led_off(Led_t *me)
{
if (me == NULL) {
return -1;
}

me->is_on = false;
led_update_hardware(me);

return 0;
}

这里的 static 使 led_update_hardware() 具有内部链接,其他源文件不能通过这个函数名直接调用它。实际组织模块时,可以将这理解为当前源文件内部使用的函数。

它与 C++ 的 private 方法都有隐藏实现的用途,但约束范围不同:static 在这里控制函数的链接范围,private 控制类成员的访问权限。给辅助函数加 static,也不会自动隐藏头文件里公开的结构体成员。

通过查询接口读取状态

读取状态也可以集中到一个函数中。这类只负责读取的接口通常称为 getter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int led_get_state(const Led_t *me, bool *is_on, uint8_t *brightness)
{
if (me == NULL) {
return -1;
}

if (is_on != NULL) {
*is_on = me->is_on;
}

if (brightness != NULL) {
*brightness = me->brightness;
}

return 0;
}

这个接口有三个需要注意的地方:

写法 作用
const Led_t *me 函数不通过 me 修改对象成员
检查输出指针是否为 NULL 调用方可以只读取需要的字段
返回错误码 对象指针为 NULL 时报告失败

调用时,先检查返回值,再使用读取结果:

1
2
3
4
5
6
bool is_on;
uint8_t brightness;

if (led_get_state(&red_led, &is_on, &brightness) == 0) {
/* 根据状态做业务判断 */
}

查询和修改分别通过不同接口完成,也便于定位状态是在何处发生变化的。

统一模块的访问方式

这份 LED 模块可以采用以下约定:

操作 使用方式
点亮 LED 调用 led_on(&led),由接口更新状态和硬件
熄灭 LED 调用 led_off(&led),由接口更新状态和硬件
查询状态 调用 led_get_state()
更新 GPIO 集中在内部辅助函数中
声明内部辅助函数 放在源文件中,并用 static 限定链接范围

从职责上看,头文件中的公开函数类似 C++ 类的公共接口,源文件中的辅助函数负责内部实现。外部调用统一接口后,硬件改动主要在模块内部处理,也更容易检查软件状态与硬件操作是否一致。