耦合原则(上):数据耦合、标记耦合、控制耦合

各位,咱们今天聊聊耦合。说实话,耦合这个词在嵌入式圈子里被念叨得最多,但真正吃透的人不多。我见过太多项目,明明硬件资源够用,软件却因为耦合太紧,改一个功能崩一片。嗯,今天咱们就从最基础的三种耦合开始掰扯。

什么是耦合?为什么我们要管它?

耦合,说白了就是模块之间的依赖关系。依赖越强,耦合越高;耦合越高,代码越难改。你想想看,一个模块改了接口,结果牵连了七八个模块都要跟着改——这种场景,我猜你肯定遇到过。

我个人习惯把耦合分成七个等级,从低到高排:数据耦合、标记耦合、控制耦合、外部耦合、公共耦合、内容耦合、还有最要命的——内容耦合。今天咱们先讲前三个,也是最常见的三种。

核心观点:耦合度越低,模块独立性越好。设计目标就是尽可能用低耦合,避免高耦合。

耦合等级金字塔 数据耦合(最低) 标记耦合 控制耦合 外部耦合 公共耦合 内容耦合 耦合度递增 → 设计目标:尽量使用数据耦合,避免控制耦合及以上

数据耦合(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 等) 最低 最高 仅限于接口参数变化时
标记耦合 复合数据结构(结构体、类对象) 中等 中等 结构体变化时波及所有使用者
控制耦合 控制标志、枚举、模式选择 较高 较低 内部逻辑变化时调用方需同步修改

我的建议:写代码时先问自己三个问题——这个参数对方真的需要吗?对方会不会只用其中一部分?对方会不会根据这个参数做不同的处理?三个问题都答「否」,才是好的接口设计。