内聚原则(下):过程内聚、时间内聚、逻辑内聚、偶然内聚的对比与实战避坑
上一章我们聊了功能内聚和顺序内聚,那是内聚光谱上的优等生。今天要讲的这四位——过程内聚、时间内聚、逻辑内聚、偶然内聚,就有点「问题学生」的味道了。
说实话,我在项目里见过最多的坑,都出自这哥几个。尤其是逻辑内聚和偶然内聚,简直是代码腐化的温床。咱们一个一个扒开看。
过程内聚:看起来像模像样,实则貌合神离
过程内聚的模块,里面的元素是按「执行顺序」凑在一起的。比如一个函数里先读传感器、再算均值、最后写日志——这三件事确实按时间顺序执行,但它们之间没有数据依赖。
你想想看,读传感器是为了拿数据,算均值是为了处理数据,写日志是为了记录。它们只是碰巧在同一个流程里出现,本质上各干各的。
我在一个车载项目中遇到过典型的过程内聚。一个函数叫 ProcessStartup(),里面依次做了:初始化CAN总线、校准GPS、检查刹车状态、播放开机音效。这四个步骤确实都是启动时要做的,但彼此没有数据交换。后来需求变了,开机音效要延迟到系统完全就绪后再播放——好嘛,整个函数得拆开重写。
// 过程内聚的典型代码
void ProcessStartup(void)
{
CAN_Init(); // 初始化CAN总线
GPS_Calibrate(); // 校准GPS
Brake_Check(); // 检查刹车状态
PlayStartupSound(); // 播放开机音效
}
怎么改?把每个步骤拆成独立函数,让调用方按需组合。说白了,过程内聚的模块往往是一个「伪顺序内聚」——它看起来有顺序,但缺少真正的数据传递。
时间内聚:把「同一时间做的事」捆在一起
时间内聚比过程内聚更松散。它只要求模块内的元素在「同一时间段」被执行,至于执行顺序?无所谓。
典型的例子是系统初始化函数。你可能会把所有外设的初始化代码塞进一个 SystemInit() 里:GPIO初始化、定时器初始化、ADC初始化、DMA初始化……它们唯一的共同点就是「都在系统启动时执行一次」。
SystemInit() 改成了状态机——那叫一个惨不忍睹。
我个人习惯的做法是:每个外设的初始化独立成函数,然后用一个调度表或配置数组来管理启动顺序。这样既保留了时间上的关联性,又避免了耦合。
// 时间内聚的改进方案
typedef struct {
void (*initFunc)(void);
uint32_t timeoutMs;
bool critical;
} InitStep;
const InitStep g_initTable[] = {
{GPIO_Init, 100, true},
{TIMER_Init, 200, true},
{ADC_Init, 500, false},
{DMA_Init, 300, false},
};
逻辑内聚:最危险的「伪复用」
逻辑内聚是我在代码评审中最常抓到的坏味道。它的表现是:一个模块里塞了几个逻辑上相似但功能不同的操作,通过一个控制参数来切换。
举个例子,一个 DataProcess() 函数,通过参数 mode 来决定是压缩数据、加密数据还是校验数据。这三个操作确实都「处理数据」,但算法完全不同,输出格式也不同。
// 逻辑内聚的典型代码
int DataProcess(uint8_t *data, uint32_t len, uint8_t mode)
{
switch (mode) {
case MODE_COMPRESS:
return Compress(data, len);
case MODE_ENCRYPT:
return Encrypt(data, len);
case MODE_CHECKSUM:
return CalcChecksum(data, len);
default:
return -1;
}
}
你可能会说:「这不挺好的吗?一个接口干三件事,多简洁。」
嗯,问题就在这里。这种设计看似复用,实则把三个本应独立的逻辑强行绑在一起。一旦加密算法需要增加密钥参数,整个函数接口就得改,压缩和校验也跟着受影响。
正确的做法是:每个操作独立成函数,用函数指针表或策略模式来管理。调用方按需选择,而不是通过一个「万能函数」来切换。
偶然内聚:代码界的「大杂烩」
偶然内聚是最低等级的内聚。模块里的元素之间没有任何有意义的关联——它们只是碰巧被放在同一个文件或函数里。
我见过最离谱的例子:一个工程师把「计算CRC校验」、「打印调试信息」、「翻转LED状态」三个函数写在了同一个 .c 文件里,理由是「这三个函数都只有几行代码,放一起省事」。
这就是典型的偶然内聚。模块内的元素既没有功能关联,也没有数据依赖,更没有时间关系。纯粹是「没地方放,先搁这儿」。
Utils、Misc、Common,那大概率就是偶然内聚。
偶然内聚的危害在于:它破坏了代码的可理解性和可维护性。新接手的人根本不知道这个模块是干嘛的,也不敢随便动里面的代码——因为看不出哪些函数之间有依赖关系。
四种内聚的对比与选择
下面这张表是我在培训时经常用的,帮你快速判断该用哪种内聚级别:
| 内聚类型 | 关联依据 | 可维护性 | 复用性 | 实战建议 |
|---|---|---|---|---|
| 过程内聚 | 执行顺序 | 中等 | 低 | 尽量拆成顺序内聚 |
| 时间内聚 | 执行时间 | 中等 | 低 | 用调度表替代硬编码 |
| 逻辑内聚 | 逻辑相似性 | 低 | 中等 | 用策略模式替代 switch |
| 偶然内聚 | 无 | 极低 | 极低 | 立即重构 |
实战避坑指南
我曾经在一个无人机飞控项目里踩过逻辑内聚的坑。当时有个 MotorControl() 函数,通过参数控制四个电机的启停、转速和方向。看起来挺合理对吧?结果后来要增加电机故障诊断功能,发现这个函数已经被 7 个模块调用了,接口改了三次,每次都要回归测试所有调用点。
从那以后,我给自己定了几条规矩:
- 如果一个函数需要超过两个参数来控制「做什么」,那它大概率是逻辑内聚,拆!
- 如果模块名里出现「And」或「Or」(比如
InitAndCheck),那它可能是过程内聚或时间内聚,考虑拆分。 - 如果文件名叫
Misc.c或Utils.c,那里面大概率藏着偶然内聚,赶紧重构。
知识结构图
下面这张图帮你理清四种内聚的关系和演化路径:
记住一句话:内聚度越高,模块的「内功」越扎实。功能内聚和顺序内聚是目标,过程内聚和时间内聚是底线,逻辑内聚和偶然内聚是红线——踩了就得改。