7-8 为何(非常量)全局变量是邪恶的
若向资深程序员征询关于良好编程实践的建议,经过片刻思考后,最可能的回答将是:“避免使用全局变量!”。这绝非空穴来风:全局变量是编程语言史上被滥用最严重的机制之一。尽管在小型学术程序中看似无害,但在大型项目中往往会引发诸多问题。
新手程序员常因全局变量易于操作而倾向大量使用,尤其在涉及多函数调用时(通过函数参数传递数据颇为麻烦)。但这通常是个糟糕的选择。许多开发者认为非const全局变量应彻底杜绝!
但在探讨原因之前,我们需要澄清一点:当开发者说全局变量是邪恶的,他们通常并非指所有全局变量,而是主要指非const全局变量。
为何(非const)全局变量是邪恶的
迄今为止,非const全局变量危险性的最大根源在于:任何被调用的函数都可能修改其值,而程序员无法轻易预知这种情况的发生。请看以下程序:
#include <iostream>
int g_mode; // declare global variable (will be zero-initialized by default)
void doSomething()
{
g_mode = 2; // set the global g_mode variable to 2
}
int main()
{
g_mode = 1; // note: this sets the global g_mode variable to 1. It does not declare a local g_mode variable!
doSomething();
// Programmer still expects g_mode to be 1
// But doSomething changed it to 2!
if (g_mode == 1)
{
std::cout << "No threat detected.\n";
}
else
{
std::cout << "Launching nuclear missiles...\n";
}
return 0;
}

请注意,程序员将变量 g_mode 设置为 1 后,随即调用了 doSomething()。除非程序员明确知道 doSomething() 会改变 g_mode 的值,否则他/她很可能并未预期 doSomething() 会改变该值!因此,main() 函数的后续部分并未按程序员预期的方式运行(世界就此毁灭)。
简而言之,全局变量会导致程序状态不可预测。每次函数调用都可能潜藏风险,而程序员无法轻易分辨哪些调用是安全的!局部变量则安全得多,因为其他函数无法直接影响它们。
还有许多其他充分理由避免使用非const全局变量。
使用全局变量时,常见如下代码片段:
void someFunction()
{
// useful code
if (g_mode == 4)
{
// do something good
}
}
调试后,你发现程序运行异常是因为g_mode的值是3而非4。如何修复?现在你需要找出所有可能将g_mode设为3的位置,并追溯其初始赋值过程——这个错误源头可能出现在完全无关的代码段中!
将局部变量声明在尽可能靠近使用位置的关键原因之一,在于此举能最大限度缩小需要排查的代码范围,从而快速理解变量功能。全局变量则恰恰相反——由于可在任意位置访问,你可能需要遍历整个程序才能掌握其用法。在小型程序中这或许不成问题,但在大型程序中则会成为难题。
例如,你可能发现程序中 g_mode 被引用了 442 次。除非 g_mode 有完善的文档说明,否则你可能需要逐一检查每个使用场景,才能理解其在不同情况下的作用、有效取值范围以及整体功能。
全局变量还会降低程序的模块化和灵活性。仅使用参数且无副作用的函数才是真正的模块化设计。模块化既有助于理解程序功能,也提升代码复用性,而全局变量会显著削弱模块化特性。
尤其应避免将全局变量用于关键的“决策点”变量(例如条件语句中的变量,如上例中的g_mode)。若仅改变存储信息值的全局变量(如用户名),程序通常不会崩溃;但若修改影响程序实际运行逻辑的全局变量,则极易导致程序故障。
最佳实践
尽可能使用局部变量而非全局变量。
全局变量的初始化顺序问题
静态变量(包括全局变量)的初始化作为程序启动的一部分,在主函数执行之前进行。该过程分为两个阶段:
第一阶段称为静态初始化static initialization。静态初始化包含两个子阶段:
- 具有constexpr初始化表达式(包括字面量)的全局变量将被初始化为该值。此过程称为常量初始化
constant initialization。 - 未指定初始化的全局变量将进行零初始化。由于0是constexpr值,零初始化被视为静态初始化的一种形式。
第二阶段称为动态初始化dynamic initialization。该阶段更为复杂且存在细微差别,其核心是初始化具有非constexpr初始化的全局变量。
以下是非constexpr初始化的示例:
int init()
{
return 5;
}
int g_something{ init() }; // non-constexpr initialization
在单个文件内,对于每个阶段,全局变量通常按定义顺序初始化(动态初始化阶段对此规则有少数例外)。因此,需注意避免变量依赖于其他变量的初始化值——这些变量可能要到后期才会初始化。例如:
#include <iostream>
int initX(); // forward declaration
int initY(); // forward declaration
int g_x{ initX() }; // g_x is initialized first
int g_y{ initY() };
int initX()
{
return g_y; // g_y isn't initialized when this is called
}
int initY()
{
return 5;
}
int main()
{
std::cout << g_x << ' ' << g_y << '\n';
}
这将输出:

更严重的问题在于,不同翻译单元中静态对象的初始化顺序存在歧义。
假设存在两个文件 a.cpp 和 b.cpp,它们各自的全局变量都可能被优先初始化。若 a.cpp 中某个静态存储期变量使用 b.cpp 中定义的静态存储期变量进行初始化,则存在 50% 的概率 b.cpp 中的变量尚未完成初始化。
命名法
不同翻译单元中具有静态存储期的对象初始化顺序的不确定性,常被称为静态初始化顺序问题。
警告
避免使用来自不同翻译单元的具有静态存储期的对象来初始化具有静态存储期的对象。
全局变量的动态初始化同样容易受到初始化顺序问题的影响,应尽可能避免。
那么使用非 const 全局变量有哪些非常充分的理由呢?
其实并不多。在大多数情况下,使用局部变量并将其作为实参传递给其他函数更为可取。但在某些特殊情况下,审慎使用非 const 全局变量反而能降低程序复杂度,这些罕见场景中,其使用效果可能优于其他替代方案。
一个很好的例子是日志文件,你可以将错误或调试信息写入其中。将其定义为全局变量是合理的,因为程序中通常只存在一个这样的日志文件,且它很可能在程序的各个部分被使用。另一个典型例子是随机数生成器(我们在第8.15节——全局随机数(Random.h)中展示了相关示例)。
值得一提的是,std::cout和std::cin对象在std命名空间内正是以全局变量形式实现的。
经验法则是:使用全局变量至少需满足两项条件:该变量所代表的对象在程序中仅存在一个实例,且其使用场景遍布整个程序。
许多新手程序员常犯的错误是:因当前仅需单一实例就认为可实现全局化。例如开发单人游戏时,你可能认为只需一个玩家对象。但当后续需要添加多人模式(对战或轮流模式)时,该如何处理?
防范全局破坏
若你确实需要使用非const全局变量,以下建议可最大限度减少潜在风险。这些建议不仅适用于非const全局变量,对所有全局变量同样有效。
首先,为所有非命名空间的全局变量添加前缀“g”或“g_”,更佳做法是将其置于命名空间中(详见第7.2节——用户定义命名空间与作用域解析运算符),以降低命名冲突的风险。
例如,应避免如下写法:
#include <iostream>
constexpr double gravity { 9.8 }; // risk of collision with some other global variable named gravity
int main()
{
std::cout << gravity << '\n'; // unclear if this is a local or global variable from the name
return 0;
}
这样做:
#include <iostream>
namespace constants
{
constexpr double gravity { 9.8 }; // will not collide with other global variables named gravity
}
int main()
{
std::cout << constants::gravity << '\n'; // clear this is a global variable (since namespaces are global)
return 0;
}
其次,与其允许直接访问全局变量,更优的做法是将变量进行“封装”。确保变量仅能在其声明的文件内部访问,例如通过声明静态变量或常量变量实现,同时提供外部全局“访问函数”来操作该变量。这些函数能确保正确使用(例如执行输入验证、范围检查等)。此外,若需变更底层实现(如更换数据库系统),只需更新访问函数即可,无需修改所有直接使用全局变量的代码。
例如,应避免如下写法:
constants.cpp:
namespace constants
{
extern const double gravity { 9.8 }; // has external linkage, can be accessed by other files
}
main.cpp:
#include <iostream>
namespace constants
{
extern const double gravity; // forward declaration
}
int main()
{
std::cout << constants::gravity << '\n'; // direct access to global variable
return 0;
}
执行以下操作:
contants.cpp:
namespace constants
{
constexpr double gravity { 9.8 }; // has internal linkage, is accessible only within this file
}
double getGravity() // has external linkage, can be accessed by other files
{
// We could add logic here if needed later
// or change the implementation transparently to the callers
return constants::gravity;
}
main.cpp:
#include <iostream>
double getGravity(); // forward declaration
int main()
{
std::cout << getGravity() << '\n';
return 0;
}
提醒
全局常量变量默认具有内部链接,重力变量无需声明为静态。
第三,当编写一个使用全局变量的独立函数时,不要在函数体内直接使用该变量。而是将其作为实参传递进去。这样,如果函数在某些情况下需要使用不同的值,只需改变实参即可。这有助于保持模块化。
避免如下写法:
#include <iostream>
namespace constants
{
constexpr double gravity { 9.8 };
}
// This function is only useful for calculating your instant velocity based on the global gravity
double instantVelocity(int time)
{
return constants::gravity * time;
}
int main()
{
std::cout << instantVelocity(5) << '\n';
return 0;
}
这样做
#include <iostream>
namespace constants
{
constexpr double gravity { 9.8 };
}
// This function can calculate the instant velocity for any gravity value (more useful)
double instantVelocity(int time, double gravity)
{
return gravity * time;
}
int main()
{
std::cout << instantVelocity(5, constants::gravity) << '\n'; // pass our constant to the function as a parameter
return 0;
}
一个C++笑话
全局变量最棒的命名前缀是什么?
答案://
这个笑话值回所有注释。

浙公网安备 33010602011771号