内聚原则(上):功能内聚、顺序内聚、通信内聚
好,咱们正式开始聊内聚。说实话,内聚这个词听起来挺学术的,但说白了就一句话:一个模块里的东西,到底该不该放在一起。
我刚开始做嵌入式那几年,也踩过不少坑。一个.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行,我建议你考虑拆成多个功能内聚的函数,然后用一个调度函数把它们串起来。
- 通信内聚要谨慎。它最容易退化成“杂货铺”式的模块。我一般只在配置管理、设备寄存器映射这类场景下用通信内聚。
一句话总结:功能内聚是目标,顺序内聚是常态,通信内聚是底线。低于通信内聚的(比如逻辑内聚、偶然内聚),趁早重构。