内聚原则(下):过程内聚、时间内聚、逻辑内聚、偶然内聚的对比与实战避坑

上一章我们聊了功能内聚和顺序内聚,那是内聚光谱上的优等生。今天要讲的这四位——过程内聚、时间内聚、逻辑内聚、偶然内聚,就有点「问题学生」的味道了。

说实话,我在项目里见过最多的坑,都出自这哥几个。尤其是逻辑内聚和偶然内聚,简直是代码腐化的温床。咱们一个一个扒开看。

过程内聚:看起来像模像样,实则貌合神离

过程内聚的模块,里面的元素是按「执行顺序」凑在一起的。比如一个函数里先读传感器、再算均值、最后写日志——这三件事确实按时间顺序执行,但它们之间没有数据依赖。

你想想看,读传感器是为了拿数据,算均值是为了处理数据,写日志是为了记录。它们只是碰巧在同一个流程里出现,本质上各干各的。

过程内聚的特征:模块内元素共享一个控制流,但不共享数据。

我在一个车载项目中遇到过典型的过程内聚。一个函数叫 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;
    }
}

你可能会说:「这不挺好的吗?一个接口干三件事,多简洁。」

嗯,问题就在这里。这种设计看似复用,实则把三个本应独立的逻辑强行绑在一起。一旦加密算法需要增加密钥参数,整个函数接口就得改,压缩和校验也跟着受影响。

我的经验:逻辑内聚的模块,往往在项目中期开始「膨胀」。今天加一个模式,明天加一个参数,最后变成一个几百行的 switch-case 怪物。我曾经接手过一个这样的模块,里面塞了 12 种数据操作模式,光看代码就花了三天。

正确的做法是:每个操作独立成函数,用函数指针表或策略模式来管理。调用方按需选择,而不是通过一个「万能函数」来切换。

偶然内聚:代码界的「大杂烩」

偶然内聚是最低等级的内聚。模块里的元素之间没有任何有意义的关联——它们只是碰巧被放在同一个文件或函数里。

我见过最离谱的例子:一个工程师把「计算CRC校验」、「打印调试信息」、「翻转LED状态」三个函数写在了同一个 .c 文件里,理由是「这三个函数都只有几行代码,放一起省事」。

这就是典型的偶然内聚。模块内的元素既没有功能关联,也没有数据依赖,更没有时间关系。纯粹是「没地方放,先搁这儿」。

偶然内聚的识别标志:你无法用一个有意义的名称来描述这个模块的职责。如果模块名只能叫 Utils、Misc、Common,那大概率就是偶然内聚。

偶然内聚的危害在于:它破坏了代码的可理解性和可维护性。新接手的人根本不知道这个模块是干嘛的,也不敢随便动里面的代码——因为看不出哪些函数之间有依赖关系。

四种内聚的对比与选择

下面这张表是我在培训时经常用的,帮你快速判断该用哪种内聚级别:

内聚类型 关联依据 可维护性 复用性 实战建议
过程内聚 执行顺序 中等 低 尽量拆成顺序内聚
时间内聚 执行时间 中等 低 用调度表替代硬编码
逻辑内聚 逻辑相似性 低 中等 用策略模式替代 switch
偶然内聚 无 极低 极低 立即重构

实战避坑指南

我曾经在一个无人机飞控项目里踩过逻辑内聚的坑。当时有个 MotorControl() 函数,通过参数控制四个电机的启停、转速和方向。看起来挺合理对吧?结果后来要增加电机故障诊断功能,发现这个函数已经被 7 个模块调用了,接口改了三次,每次都要回归测试所有调用点。

从那以后,我给自己定了几条规矩:

  • 如果一个函数需要超过两个参数来控制「做什么」,那它大概率是逻辑内聚,拆!
  • 如果模块名里出现「And」或「Or」(比如 InitAndCheck),那它可能是过程内聚或时间内聚,考虑拆分。
  • 如果文件名叫 Misc.c 或 Utils.c,那里面大概率藏着偶然内聚,赶紧重构。
特别提醒:不要为了「减少文件数量」或「省几个函数调用开销」而牺牲内聚性。嵌入式系统的性能瓶颈通常不在函数调用上,而在算法和 I/O 上。为了那点微乎其微的性能提升,把代码搞成一团浆糊,不值得。

知识结构图

下面这张图帮你理清四种内聚的关系和演化路径:

内聚光谱:从低到高 偶然内聚 逻辑内聚 时间内聚 过程内聚 内聚度越高,模块独立性越强 • 偶然内聚:元素无关联,纯粹凑在一起 • 逻辑内聚:通过控制参数切换相似操作 • 时间内聚:同一时间段执行的操作 • 过程内聚:按执行顺序排列的操作 重构方向 拆分为独立函数 策略模式/函数指针表 调度表/配置数组

记住一句话:内聚度越高,模块的「内功」越扎实。功能内聚和顺序内聚是目标,过程内聚和时间内聚是底线,逻辑内聚和偶然内聚是红线——踩了就得改。