10、全局变量的治理:单例模式替代全局变量、上下文结构体传递、依赖注入入门
全局变量,嗯,这大概是每个C程序员都绕不开的话题。我早期做嵌入式项目时,代码里到处都是extern int g_system_status;这种写法。当时觉得挺方便,谁想用谁拿。直到有一次,一个中断服务程序和一个普通任务同时修改了这个变量,系统直接崩溃。从那以后,我对全局变量就格外敏感。
说白了,全局变量的问题不在于它本身,而在于它破坏了模块之间的边界。你想想看,一个变量谁都能改,出了问题你根本不知道是谁干的。这就像一栋大楼,每个房间的门都敞开着,谁都能进来翻东西。今天我们要聊的,就是怎么把这些「敞开的门」管起来。
核心观点:全局变量不是不能用,而是要「有节制地用、有结构地用、有控制地用」。治理全局变量,本质上是在治理代码的耦合度。
10.1 全局变量的三大罪状
我总结了一下,全局变量主要有三个让人头疼的地方:
- 隐式依赖:函数A改了全局变量,函数B依赖这个变量。但你看函数B的签名,完全看不出来它依赖了谁。这种「看不见的依赖」最要命。
- 线程不安全:多线程环境下,全局变量就是一颗定时炸弹。加锁?加在哪里?加多少?我见过一个项目,全局变量加了七八把锁,最后死锁问题比全局变量本身还难搞。
- 测试困难:你想单元测试一个函数,结果它依赖了三个全局变量。你得先把这三个变量初始化好,测试完还得恢复现场。这哪是测试,这分明是在考古。
我的经验:判断一个全局变量是否该存在,有个简单标准——如果这个变量在系统启动后只初始化一次,之后只读不写,那它勉强可以接受。否则,请考虑下面的替代方案。
10.2 方案一:单例模式替代全局变量
单例模式,说白了就是「全局只有一个实例,但通过函数来访问」。这样你至少能控制访问入口,加个日志、加个锁都方便。
我习惯这样写:
// logger.h
typedef struct {
int log_level;
FILE *output;
void (*write)(const char *msg);
} Logger;
Logger* logger_get_instance(void);
// logger.c
static Logger instance = {0};
static int initialized = 0;
Logger* logger_get_instance(void) {
if (!initialized) {
instance.log_level = 3;
instance.output = stdout;
instance.write = default_write;
initialized = 1;
}
return &instance;
}
你看,外部代码只能通过logger_get_instance()拿到这个实例。想改?可以,但得经过我允许。我在里面加个锁、加个引用计数,都方便。
注意:单例模式不是万能药。它只是把「全局变量」变成了「全局对象」,本质上还是全局的。如果你的系统有多个实例需求(比如两个日志文件),单例就不合适了。
10.3 方案二:上下文结构体传递
这是我最推荐的方式。把相关的全局变量打包成一个结构体,然后通过函数参数传递。你想想看,这样函数的依赖关系就一目了然了。
// 不好的写法
int g_temperature = 25;
int g_humidity = 60;
int g_pressure = 1013;
void process_sensor_data(void) {
if (g_temperature > 30) {
// 处理高温
}
}
// 好的写法
typedef struct {
int temperature;
int humidity;
int pressure;
} SensorContext;
void process_sensor_data(const SensorContext *ctx) {
if (ctx->temperature > 30) {
// 处理高温
}
}
我在一个工业控制项目里用过这种方式。当时有十几个传感器,每个传感器都有自己的校准参数、状态标志。我把它们全部打包成SensorContext,每个传感器模块初始化时传入自己的上下文。代码瞬间清晰了,测试也好写了——构造一个上下文结构体,传进去就行。
关键点:上下文结构体传递,本质上是把「隐式依赖」变成了「显式依赖」。函数的参数列表就是它的依赖清单,一目了然。
10.4 方案三:依赖注入入门
依赖注入听起来高大上,其实核心思想很简单:别让函数自己去拿依赖,你把依赖给它。
举个例子:
// 不好的写法——函数自己去拿全局变量
void save_report(void) {
Database *db = get_global_database();
db->insert(report_data);
}
// 好的写法——依赖注入
void save_report(Database *db, const ReportData *data) {
if (db == NULL || data == NULL) return;
db->insert(data);
}
你发现没有?依赖注入和上下文结构体传递,本质上是一回事。只不过依赖注入更强调「把依赖作为参数传进来」,而不是「自己去外面找」。
我曾经重构过一个遗留系统,里面有个模块直接调用了fopen()写日志。测试的时候根本没法测,因为文件路径是写死的。后来我改成依赖注入,把文件操作接口作为参数传进去。测试时传一个内存模拟的文件接口,完美解决。
入门建议:别一上来就搞什么依赖注入框架。C语言里,最简单的依赖注入就是「函数指针 + 结构体参数」。先把这个用熟,再考虑更复杂的方案。
10.5 三种方案的对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 单例模式 | 系统级服务(日志、配置、时钟) | 控制访问入口,易于加锁 | 仍是全局的,多实例困难 |
| 上下文结构体 | 模块内部状态管理 | 依赖显式化,测试友好 | 参数传递稍显繁琐 |
| 依赖注入 | 模块间解耦,可替换实现 | 高度灵活,便于测试 | 设计复杂度增加 |
10.6 实战建议:如何逐步治理
如果你面对的是一个遗留系统,里面已经有上百个全局变量,别想着一天改完。我建议分三步走:
- 先分类:把全局变量分成三类——只读的、模块内部的、跨模块共享的。只读的暂时不动,模块内部的用上下文结构体收起来,跨模块共享的考虑单例或依赖注入。
- 再封装:给每个全局变量加一层访问函数。哪怕你暂时不改结构,先让外部代码通过函数访问。这样以后改起来方便。
- 最后重构:逐个模块进行重构。每改一个模块,就把它依赖的全局变量全部收进上下文或通过参数传入。
避坑指南:我曾经在一个项目里,试图一次性把所有全局变量改成上下文结构体。结果改了三天,编译过了,跑起来全是段错误。后来学乖了,每次只改一个模块,改完测试通过再改下一个。
10.7 知识体系总览
下面这张图,是我对全局变量治理思路的总结。你可以把它当作一个决策树来用:
这张图的核心逻辑是:先判断变量的性质,再选择合适的治理方案。别一上来就想着「我要用依赖注入」,先问问自己——这个变量真的需要跨模块共享吗?
最后说一句:治理全局变量,本质上是在治理代码的「可见性」。你让一个变量可见的范围越小,代码就越容易理解和维护。这个道理,放在任何语言里都适用。
公众号:蓝海资料掘金营,微信deep3321