耦合原则(上):数据耦合、标记耦合、控制耦合
各位,咱们今天聊聊耦合。说实话,耦合这个词在嵌入式圈子里被念叨得最多,但真正吃透的人不多。我见过太多项目,明明硬件资源够用,软件却因为耦合太紧,改一个功能崩一片。嗯,今天咱们就从最基础的三种耦合开始掰扯。
什么是耦合?为什么我们要管它?
耦合,说白了就是模块之间的依赖关系。依赖越强,耦合越高;耦合越高,代码越难改。你想想看,一个模块改了接口,结果牵连了七八个模块都要跟着改——这种场景,我猜你肯定遇到过。
我个人习惯把耦合分成七个等级,从低到高排:数据耦合、标记耦合、控制耦合、外部耦合、公共耦合、内容耦合、还有最要命的——内容耦合。今天咱们先讲前三个,也是最常见的三种。
核心观点:耦合度越低,模块独立性越好。设计目标就是尽可能用低耦合,避免高耦合。
数据耦合(Data Coupling)
定义:模块之间只通过简单数据参数传递信息。比如传一个 int、一个 float、一个 char。这是最理想的耦合方式。
说白了,就是我给你什么,你就用什么,别管我内部怎么折腾。数据耦合的模块,接口最干净,测试最容易,复用性最高。
来看个例子。假设我们要做一个温度采集系统,一个模块负责读传感器,另一个模块负责显示温度:
/* ========== 传感器模块 ========== */
// 只返回一个 float 温度值
float sensor_read_temperature(void) {
uint16_t raw = adc_get_value(ADC_CHANNEL_1);
float temp = (raw * 3.3f / 4096.0f - 0.5f) / 0.01f;
return temp;
}
/* ========== 显示模块 ========== */
// 只接收一个 float 温度值
void display_show_temperature(float temp_celsius) {
char buf[16];
snprintf(buf, sizeof(buf), "%.1f°C", temp_celsius);
lcd_set_text(LCD_LINE_1, buf);
}
/* ========== 主控模块 ========== */
void main_loop(void) {
float temp = sensor_read_temperature();
display_show_temperature(temp);
}
你看,传感器模块和显示模块之间只传递了一个 float。传感器不知道显示模块怎么画界面,显示模块也不关心传感器是用的热电偶还是热敏电阻。这就是数据耦合——干净、利落。
我的经验:数据耦合的模块,单元测试最好写。你只需要构造输入数据,验证输出数据就行,不用 mock 一堆东西。我在做车载仪表盘项目时,所有传感器驱动都强制要求用数据耦合,测试覆盖率轻松拉到 90% 以上。
标记耦合(Stamp Coupling)
定义:模块之间传递的是复合数据结构(结构体、类对象),但接收方只用到了其中的一部分字段。
这就有点问题了。你传了一个大结构体过去,对方只用了两个字段。那剩下的字段干嘛用?没用。但没用你还得带着,因为接口就这么定义的。更麻烦的是,结构体一改,所有用到它的模块都得重新编译。
我举个例子你就明白了:
/* ========== 定义数据结构 ========== */
typedef struct {
float temperature; // 温度
float humidity; // 湿度
float pressure; // 气压
uint32_t timestamp; // 时间戳
uint8_t sensor_id; // 传感器ID
uint8_t status; // 状态码
} SensorData_t;
/* ========== 日志模块 ========== */
// 明明只需要 temperature 和 timestamp
void log_sensor_data(SensorData_t *data) {
printf("[%lu] Temp: %.1f\n", data->timestamp, data->temperature);
}
/* ========== 报警模块 ========== */
// 明明只需要 temperature 和 status
void check_alarm(SensorData_t *data) {
if (data->temperature > 80.0f && data->status == 0x01) {
activate_alarm();
}
}
看到了吗?log_sensor_data 和 check_alarm 都接收了整个 SensorData_t 结构体,但各自只用了一小部分字段。这就是典型的标记耦合。
避坑指南:我曾经在一个 BMS(电池管理系统)项目里,把电池的所有参数塞进一个巨大的结构体,然后到处传。结果后来要加一个电池型号字段,整个系统 30 多个文件都要重新编译。改一个字段,编译半小时。从那以后,我定了个规矩:接口参数尽量用基本类型,如果必须用结构体,就拆成最小粒度的专用结构体。
怎么改?很简单,按需传参:
// 日志模块只接收它需要的参数
void log_sensor_data(float temperature, uint32_t timestamp) {
printf("[%lu] Temp: %.1f\n", timestamp, temperature);
}
// 报警模块只接收它需要的参数
void check_alarm(float temperature, uint8_t status) {
if (temperature > 80.0f && status == 0x01) {
activate_alarm();
}
}
这样改完,接口变多了?没错。但每个接口都只做一件事,参数一目了然。而且结构体改了,只要不涉及这些参数,模块就不用动。这就是用接口数量换耦合度,值不值?我个人觉得非常值。
控制耦合(Control Coupling)
定义:一个模块通过参数控制另一个模块的内部逻辑流程。说白了,就是 A 模块告诉 B 模块「你该怎么做」,而不是「你要什么数据」。
这是最危险的一种耦合。为什么?因为控制耦合意味着调用方需要知道被调用方的内部实现细节。一旦被调用方的逻辑变了,调用方也得跟着改。
来看个反面教材:
/* ========== 数据处理模块 ========== */
// 通过 flag 控制内部行为
void process_data(int value, int mode) {
switch (mode) {
case 0: // 原始数据
save_raw(value);
break;
case 1: // 滤波后数据
int filtered = low_pass_filter(value);
save_filtered(filtered);
break;
case 2: // 归一化数据
float normalized = (float)value / 1024.0f;
save_normalized(normalized);
break;
default:
break;
}
}
/* ========== 调用方 ========== */
void sensor_task(void) {
int raw = read_sensor();
process_data(raw, 0); // 我要原始数据
// ... 过一会儿
process_data(raw, 1); // 我要滤波数据
}
问题在哪?process_data 内部有 3 条路径,由 mode 参数控制。调用方必须知道 mode=0 是原始数据,mode=1 是滤波数据。哪天要加个 mode=3 做傅里叶变换,所有调用方都得检查要不要改。
更好的做法是拆成独立函数:
// 每个功能一个独立函数
void save_raw_data(int value);
void save_filtered_data(int value);
void save_normalized_data(int value);
// 调用方按需调用
void sensor_task(void) {
int raw = read_sensor();
save_raw_data(raw);
// ... 过一会儿
save_filtered_data(raw);
}
这样改完,每个函数只做一件事。调用方不需要知道内部实现,只需要知道「我要保存原始数据」就行。这就是从控制耦合降到了数据耦合。
判断标准:如果你在写 switch 或 if-else 时,发现分支条件是从外部传进来的参数,那大概率就是控制耦合。赶紧拆。
三种耦合对比
| 耦合类型 | 传递内容 | 依赖程度 | 可测试性 | 修改影响范围 |
|---|---|---|---|---|
| 数据耦合 | 基本数据类型(int, float, char 等) | 最低 | 最高 | 仅限于接口参数变化时 |
| 标记耦合 | 复合数据结构(结构体、类对象) | 中等 | 中等 | 结构体变化时波及所有使用者 |
| 控制耦合 | 控制标志、枚举、模式选择 | 较高 | 较低 | 内部逻辑变化时调用方需同步修改 |
我的建议:写代码时先问自己三个问题——这个参数对方真的需要吗?对方会不会只用其中一部分?对方会不会根据这个参数做不同的处理?三个问题都答「否」,才是好的接口设计。