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 实战建议:如何逐步治理

如果你面对的是一个遗留系统,里面已经有上百个全局变量,别想着一天改完。我建议分三步走:

  1. 先分类:把全局变量分成三类——只读的、模块内部的、跨模块共享的。只读的暂时不动,模块内部的用上下文结构体收起来,跨模块共享的考虑单例或依赖注入。
  2. 再封装:给每个全局变量加一层访问函数。哪怕你暂时不改结构,先让外部代码通过函数访问。这样以后改起来方便。
  3. 最后重构:逐个模块进行重构。每改一个模块,就把它依赖的全局变量全部收进上下文或通过参数传入。

避坑指南:我曾经在一个项目里,试图一次性把所有全局变量改成上下文结构体。结果改了三天,编译过了,跑起来全是段错误。后来学乖了,每次只改一个模块,改完测试通过再改下一个。

10.7 知识体系总览

下面这张图,是我对全局变量治理思路的总结。你可以把它当作一个决策树来用:

全局变量治理决策树 发现全局变量 是否只读且初始化后不变? 保留为全局常量 加 const 修饰,只读访问 是否只在单个模块内使用? 上下文结构体传递 打包成 struct,通过参数传递 是否系统级服务(日志/配置)? 单例模式 通过函数访问,控制入口 依赖注入 通过参数传入依赖

这张图的核心逻辑是:先判断变量的性质,再选择合适的治理方案。别一上来就想着「我要用依赖注入」,先问问自己——这个变量真的需要跨模块共享吗?

最后说一句:治理全局变量,本质上是在治理代码的「可见性」。你让一个变量可见的范围越小,代码就越容易理解和维护。这个道理,放在任何语言里都适用。


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