模块化编程:头文件设计原则、源文件分离、信息隐藏、静态全局变量与函数

说实话,模块化编程这个话题,我每次带新人时都要反复讲。很多人觉得不就是把代码拆成几个文件嘛,有什么难的?但我在项目中见过太多因为模块化没做好,后期维护时想哭都哭不出来的案例。

今天咱们就聊聊,怎么把模块化编程做到位。核心就四个字:高内聚,低耦合

一、头文件设计原则——接口要小,承诺要少

头文件是什么?说白了就是模块对外公布的接口说明书。你想想看,别人用你的模块时,只需要看头文件就够了。所以头文件的设计质量,直接决定了模块的易用性。

我个人习惯遵循这几个原则:

  • 只暴露必要的接口——能放在源文件里的,就别放到头文件里
  • 不要包含不需要的头文件——减少编译依赖,加快编译速度
  • 使用包含守卫——防止重复包含
  • 不要定义变量——头文件里只声明,不定义

核心原则:头文件是契约,不是实现。别人只需要知道"能调用什么函数",不需要知道"函数怎么实现的"。

来看一个典型的头文件写法:

/* timer_module.h */
#ifndef TIMER_MODULE_H
#define TIMER_MODULE_H

#include <stdint.h>   /* 只包含必要的头文件 */

/* 公开的类型定义 */
typedef struct {
    uint32_t interval_ms;
    void (*callback)(void);
} TimerConfig_t;

/* 公开的函数接口 */
int  Timer_Init(TimerConfig_t *config);
void Timer_Start(void);
void Timer_Stop(void);

#endif /* TIMER_MODULE_H */

注意看,这里没有暴露任何内部变量,也没有包含那些用不到的库。干净利落。

二、源文件分离——各司其职,互不干扰

源文件怎么分?我见过有人把所有代码塞进一个 main.c,结果文件上万行。嗯,这种代码我接手过一次,改一个bug花了三天,因为根本找不到函数在哪里定义的。

我的建议是:一个模块一个 .c 文件,配套一个 .h 文件。模块的粒度怎么把握?

  • 功能独立的硬件驱动(如 UART、I2C)各成一个模块
  • 业务逻辑层按功能域划分(如 通信协议、数据处理、状态机)
  • 工具函数可以归到一个 utils 模块

举个例子,一个温控系统的文件结构:

project/
├── main.c
├── temperature_sensor.c
├── temperature_sensor.h
├── heater_control.c
├── heater_control.h
├── pid_controller.c
├── pid_controller.h
├── uart_comm.c
├── uart_comm.h
└── utils.c
    utils.h

每个模块只做一件事。温度传感器只管读温度,PID控制器只管算控制量,加热器只管执行。这样改一个模块时,其他模块基本不用动。

三、信息隐藏——不该看的,别让人看到

信息隐藏是模块化编程的灵魂。说白了就是:模块内部怎么折腾,那是它自己的事,外部不需要知道

我在项目中遇到过一个问题:有个同事把模块内部的结构体定义在了头文件里,结果其他模块直接访问了结构体成员。后来内部结构改了,所有引用它的地方都得跟着改。这就是信息隐藏没做好的代价。

正确的做法是:

  • 内部结构体定义在 .c 文件中
  • 内部函数用 static 修饰
  • 只通过函数接口与外部交互

小技巧:如果你必须暴露一个结构体给外部,但又不想让别人直接访问成员,可以使用"不透明指针"(opaque pointer)。在头文件里只做前向声明,定义留在 .c 里。

/* timer_module.h */
typedef struct TimerCtrl TimerCtrl_t;  /* 前向声明,外部不知道内部成员 */

TimerCtrl_t* Timer_Create(uint32_t interval_ms);
void Timer_Destroy(TimerCtrl_t *timer);

/* timer_module.c */
struct TimerCtrl {
    uint32_t interval_ms;
    uint64_t last_tick;
    void (*callback)(void);
    uint8_t  is_running;
};

这样外部只能通过 Timer_Create 拿到一个指针,但完全不知道里面有什么。想改内部结构?随便改,外部代码一个字符都不用动。

四、静态全局变量与函数——模块的"私房钱"

static 关键字在模块化编程里有两个用途,很多人只知其一不知其二。

用途一:限制作用域

在函数内部用 static,变量会保持值不变,但只在函数内可见。在文件级别用 static,变量或函数只在本文件内可见。

/* temperature_sensor.c */
static int last_temperature = 0;   /* 只有本文件能访问 */
static uint8_t sensor_initialized = 0;

static int read_adc_value(void) {   /* 内部函数,外部不可见 */
    /* 读取ADC寄存器... */
    return adc_result;
}

int Temperature_GetCurrent(void) {
    if (!sensor_initialized) {
        return -1;
    }
    last_temperature = read_adc_value();
    return last_temperature;
}

你看,last_temperatureread_adc_value 都是模块内部的东西。外部只能通过 Temperature_GetCurrent 获取温度值。这就是信息隐藏的具体实现。

用途二:保持状态

静态全局变量可以在多次函数调用之间保持状态。比如记录模块是否已初始化、累计计数等。

注意:静态全局变量虽然方便,但不要滥用。如果一个模块里有超过3-5个静态全局变量,就要考虑是不是设计上出了问题。我曾经接手过一个模块,里面20多个静态变量,互相依赖,改一个就崩一片。那滋味,真不好受。

五、知识体系总览

下面这张图,把模块化编程的核心逻辑串起来了。你可以对照着看看,自己平时有没有做到位。

模块化编程核心知识体系 模块化编程 头文件设计原则 源文件分离 信息隐藏 static 变量与函数 只暴露接口 包含守卫 不定义变量 一模块一文件 按功能域划分 减少编译依赖 内部结构体在 .c 不透明指针 只通过函数交互 限制作用域 保持状态 避免滥用 目标:高内聚 · 低耦合 · 易维护 · 可复用

六、避坑指南

最后分享几个我踩过的坑,希望能帮你少走弯路。

我曾经犯过的错:

  • 在头文件里定义了一个全局变量 int g_flag,结果多个 .c 文件都包含它,链接时报重复定义。后来改成 extern 声明,在某个 .c 里定义一次才解决。
  • 为了省事,把模块内部的结构体定义在了头文件里。后来需求变更要加字段,所有引用该结构体的地方都得重新编译。那次编译了整整20分钟。
  • 一个模块里用了8个静态全局变量,互相之间有依赖关系。调试时发现某个变量被意外修改了,查了三天才发现是另一个函数在某个条件下改了它。

我的建议:

  • 头文件里只放"别人需要知道的东西"
  • 能用 static 的地方,绝不用全局
  • 每个模块的静态全局变量控制在3个以内
  • 模块之间的依赖关系画成图,一目了然

模块化编程说白了就是"各扫门前雪"。每个模块管好自己的事,对外只提供清晰的接口。这样代码才能经得起时间考验。你想想看,半年后你自己回头看代码,是不是也希望一眼就能看懂每个模块是干什么的?


公众号:蓝海资料掘金营,微信deep3321