内聚原则(上):功能内聚、顺序内聚、通信内聚

好,咱们正式开始聊内聚。说实话,内聚这个词听起来挺学术的,但说白了就一句话:一个模块里的东西,到底该不该放在一起。

我刚开始做嵌入式那几年,也踩过不少坑。一个.c文件写了两三千行,什么功能都往里塞,结果改一个bug引出三个新bug。后来才明白,内聚搞不好,后面全是债。

这一章我们先讲三种最基础、也最常用的内聚类型:功能内聚、顺序内聚和通信内聚。它们是从高到低排的,功能内聚最高,通信内聚次之,顺序内聚居中。咱们一个一个来。

1. 功能内聚:一个函数只干一件事

功能内聚,是所有内聚类型里最理想的。它的定义很简单:模块内的所有元素,共同完成一个单一的功能。你想想看,一个函数只做一件事,而且把这件事做到极致,这就是功能内聚。

举个例子,计算CRC校验值:

// 功能内聚:只做CRC计算
uint32_t calculate_crc32(const uint8_t *data, uint32_t len) {
    uint32_t crc = 0xFFFFFFFF;
    for (uint32_t i = 0; i < len; i++) {
        crc ^= data[i];
        for (int j = 0; j < 8; j++) {
            if (crc & 1) {
                crc = (crc >> 1) ^ 0xEDB88320;
            } else {
                crc >>= 1;
            }
        }
    }
    return crc ^ 0xFFFFFFFF;
}

这个函数,输入是数据和长度,输出是CRC32值。它不负责从Flash读数据,也不负责把结果写到某个寄存器。它就是算CRC,干干净净。

功能内聚的判断标准:如果你能用一个简单的动词+名词来描述这个函数在做什么(比如“计算CRC”、“初始化定时器”、“发送CAN帧”),那它就是功能内聚的。

我在项目中遇到过最典型的反面教材:一个叫 process_data() 的函数,里面既做滤波,又做格式转换,还顺带更新了LED状态。这种函数,你改滤波算法的时候,得小心别把LED搞闪了。这就是内聚度低的表现。

2. 顺序内聚:数据流像流水线

顺序内聚,指的是模块内的元素按顺序执行,前一个的输出是后一个的输入。就像工厂里的流水线,每个工位处理完,传给下一个。

这种内聚比功能内聚低一个档次,因为模块内部其实包含了多个步骤。但好在这些步骤是强相关的,而且数据流向清晰。

看个实际例子——处理ADC采样数据:

// 顺序内聚:数据依次经过三个处理阶段
void process_adc_samples(int16_t *raw, int16_t *filtered, uint32_t count) {
    // 第一步:去除直流偏置
    remove_dc_offset(raw, count);
    
    // 第二步:低通滤波
    lowpass_filter(raw, filtered, count);
    
    // 第三步:计算有效值
    calculate_rms(filtered, count);
}

你看,这三个步骤必须按顺序来。不去掉直流偏置就滤波,结果会偏;不滤波就算RMS,噪声会混进去。这就是顺序内聚的典型特征——步骤之间有数据依赖。

我的经验:顺序内聚虽然不如功能内聚纯粹,但在嵌入式系统里非常常见。尤其是信号处理、协议栈这类场景,天然就是流水线式的。我一般会接受顺序内聚,但会确保每个步骤本身是功能内聚的。

嗯,这里要注意:顺序内聚和功能内聚的区别在于,顺序内聚的模块内部有多个“子功能”,而且它们必须按特定顺序执行。如果你能把每个步骤拆成独立的函数,并且外部调用者可以自由组合顺序,那它就变成了功能内聚。

3. 通信内聚:共享数据,但各干各的

通信内聚,指的是模块内的元素操作同一份数据,但彼此之间没有顺序依赖。它们只是因为“都要用同一个数据”而被放在了一起。

举个例子,一个管理系统配置的结构体:

// 通信内聚:都操作同一个配置结构体
typedef struct {
    uint32_t baudrate;
    uint8_t  parity;
    uint8_t  stop_bits;
    uint8_t  data_bits;
} uart_config_t;

void uart_set_baudrate(uart_config_t *cfg, uint32_t baud) {
    cfg->baudrate = baud;
}

void uart_set_parity(uart_config_t *cfg, uint8_t parity) {
    cfg->parity = parity;
}

void uart_set_stop_bits(uart_config_t *cfg, uint8_t stop) {
    cfg->stop_bits = stop;
}

这三个函数都操作 uart_config_t,但设置波特率和设置校验位之间没有顺序要求。你先设波特率还是先设校验位,结果都一样。它们只是因为“都是配置UART的参数”而被放在了一起。

我曾经踩过的坑:有一次我把传感器校准参数和通信参数放在同一个结构体里,还美其名曰“系统参数”。结果校准模块和通信模块都要包含这个头文件,一改就全编译。后来拆成两个独立的配置结构体,编译时间直接砍半。通信内聚虽然能用,但别把不相干的数据硬凑在一起。

三种内聚的对比

为了让你看得更清楚,我整理了一个表格:

内聚类型 核心特征 模块内元素关系 典型场景 内聚等级
功能内聚 一个函数只做一件事 共同完成单一功能 CRC计算、PID控制、FIFO操作 最高
顺序内聚 前一步输出是后一步输入 有数据依赖,必须按序执行 信号处理流水线、协议解析 中等
通信内聚 操作同一份数据,但无顺序依赖 共享数据,独立操作 配置管理、状态寄存器访问 较低

知识结构图:三种内聚的关系

下面这张图,帮你理清这三种内聚在模块设计中的位置:

内聚原则(上):三种内聚类型 软件模块 功能内聚 单一功能 无副作用 可独立测试 顺序内聚 数据流水线 步骤有依赖 必须按序执行 通信内聚 共享数据 无顺序依赖 独立操作 设计建议 优先使用功能内聚,接受顺序内聚,谨慎使用通信内聚 内聚度越高,模块的可维护性和可复用性越好

实际项目中的选择策略

说了这么多理论,你可能会问:那我到底该用哪种?

我的建议很简单:

  • 能拆成功能内聚的,一定拆。比如计算、校验、转换这类纯函数,没有理由不做到功能内聚。
  • 顺序内聚可以接受,但要控制模块大小。如果一个顺序内聚的模块超过200行,我建议你考虑拆成多个功能内聚的函数,然后用一个调度函数把它们串起来。
  • 通信内聚要谨慎。它最容易退化成“杂货铺”式的模块。我一般只在配置管理、设备寄存器映射这类场景下用通信内聚。

一句话总结:功能内聚是目标,顺序内聚是常态,通信内聚是底线。低于通信内聚的(比如逻辑内聚、偶然内聚),趁早重构。