全局数据(Global Data):坏味道识别与重构实战指南

24种代码坏味道系列 · 第5篇


1. 开篇场景

你是否遇到过这样的代码:多个函数都在使用全局变量 globalUserNameglobalUserCount,当你修改一个函数时,不知道会不会影响其他函数,就像在一个共享的记事本上写字,不知道其他人是否也在同时修改?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::string globalUserName;
int globalUserCount = 0;
bool globalIsLoggedIn = false;

void login(std::string username) {
globalUserName = username;
globalUserCount++;
globalIsLoggedIn = true;
}

void someOtherFunction() {
// 直接使用全局变量,不知道是谁设置的
if (globalIsLoggedIn) {
// 处理逻辑...
}
}

这就是全局数据的典型症状。全局变量可以在任何地方被访问和修改,导致代码之间的隐式依赖关系,就像房间里有一个所有人都能随意使用的开关,你不知道什么时候会被谁打开或关闭。

当你需要测试某个函数时,必须确保全局变量处于正确的状态。当你修改全局变量时,很难确定会影响哪些函数。更糟糕的是,多线程环境下,全局变量会导致竞态条件。


2. 坏味道定义

全局数据是指使用全局变量存储状态,导致代码之间产生隐式依赖,难以测试和维护。

就像一个没有锁的共享储物柜,任何人都可以随意存取物品,你不知道物品什么时候会被拿走或替换。

核心问题:全局数据破坏了封装性,使得代码之间的依赖关系不明确,增加了理解和维护的难度。


3. 识别特征

🔍 代码表现:

  • 特征1:在文件作用域定义的变量(非 const
  • 特征2:多个函数访问同一个全局变量
  • 特征3:函数的行为依赖于全局变量的状态
  • 特征4:测试时需要设置全局变量的初始状态
  • 特征5:使用 extern 关键字在多个文件间共享变量

🎯 出现场景:

  • 场景1:快速原型开发时,使用全局变量传递数据
  • 场景2:从C语言迁移到C++时,保留了全局变量的习惯
  • 场景3:单例模式使用不当,变成了全局变量
  • 场景4:配置信息使用全局变量存储

💡 快速自检:

  • 问自己:这个变量是否真的需要在全局作用域?
  • 问自己:如果删除这个全局变量,需要修改多少函数?
  • 工具提示:使用静态分析工具检测全局变量的使用情况

4. 危害分析

🚨 维护成本:修改全局变量需要检查所有使用处,时间成本增加60%

⚠️ 缺陷风险:全局状态导致不可预测的行为,bug风险增加80%

🧱 扩展障碍:添加新功能时需要考虑全局状态的影响

🤯 认知负担:需要理解全局状态才能理解函数行为,心理负担重


5. 重构实战

步骤1:安全准备

  • ✅ 确保有完整的单元测试覆盖
  • ✅ 创建重构分支:git checkout -b refactor/encapsulate-global-data
  • ✅ 使用版本控制,便于回滚

步骤2:逐步重构

重构前(问题代码)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 坏味道:全局变量
std::string globalUserName;
int globalUserCount = 0;
bool globalIsLoggedIn = false;

class BadExample {
public:
void login(std::string username) {
globalUserName = username;
globalUserCount++;
globalIsLoggedIn = true;
std::cout << "User logged in: " << globalUserName << std::endl;
}

void logout() {
if (globalIsLoggedIn) {
std::cout << "User logged out: " << globalUserName << std::endl;
globalIsLoggedIn = false;
globalUserCount--;
}
}

void printStatus() {
std::cout << "Current user: " << globalUserName
<< ", Count: " << globalUserCount << std::endl;
}
};

问题分析

  • 全局变量可以在任何地方被修改,难以追踪
  • 函数的行为依赖于全局状态,难以测试
  • 多线程环境下不安全

重构后(清洁版本)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ✅ 使用类封装状态
class UserSession {
private:
std::string userName;
int userCount;
bool isLoggedIn;

public:
UserSession() : userCount(0), isLoggedIn(false) {}

void login(const std::string& username) {
userName = username;
userCount++;
isLoggedIn = true;
std::cout << "User logged in: " << userName << std::endl;
}

void logout() {
if (isLoggedIn) {
std::cout << "User logged out: " << userName << std::endl;
isLoggedIn = false;
userCount--;
}
}

void printStatus() const {
std::cout << "Current user: " << userName
<< ", Count: " << userCount << std::endl;
}

bool getIsLoggedIn() const { return isLoggedIn; }
std::string getUserName() const { return userName; }
};

class GoodExample {
private:
UserSession session; // ✅ 封装在类中

public:
void login(std::string username) {
session.login(username);
}

void logout() {
session.logout();
}

void printStatus() {
session.printStatus();
}
};

关键变化点

  1. 封装全局变量(Encapsulate Variable)

    • 将全局变量封装到 UserSession 类中
    • 通过方法控制对状态的访问
  2. 提高可测试性

    • 每个 GoodExample 实例有自己的 session
    • 测试时可以创建独立的实例,互不影响
  3. 提高线程安全性

    • 每个实例有自己的状态,减少共享状态

步骤3:重构技巧总结

使用的重构手法

  • 封装变量(Encapsulate Variable):将全局变量封装到类中
  • 用类替换数据值(Replace Data Value with Object):将数据组合成对象

注意事项

  • ⚠️ 如果全局变量是配置信息,考虑使用配置类或依赖注入
  • ⚠️ 如果全局变量是常量,可以使用 constconstexpr
  • ⚠️ 重构后要更新所有使用全局变量的地方

6. 预防策略

🛡️ 编码时:

  • 即时检查

    • 避免在文件作用域定义非 const 变量
    • 使用类封装相关状态
    • 优先使用局部变量和参数传递
  • 小步提交

    • 发现全局变量时,立即封装
    • 使用”封装变量”重构,保持状态局部化

🔍 Code Review清单:

  • 重点检查

    • 是否有全局变量(非 const
    • 全局变量是否可以被封装
    • 函数是否过度依赖全局状态
  • 拒绝标准

    • const 的全局变量
    • 多个函数共享的可变全局状态
    • 测试时需要设置全局变量的代码

⚙️ 自动化防护:

  • IDE配置

    • 启用全局变量警告
    • 使用静态分析工具检测全局变量使用
  • CI/CD集成

    • 在CI流水线中集成静态分析工具
    • 检测全局变量使用,生成警告报告

下一篇预告:可变数据(Mutable Data)- 如何保护数据不被意外修改