邪恶的非常量全局变量

邪恶的非常量全局变量

·

2 min read

在编程过程中,避免使用全局变量是一个良好的编程实践建议。当然,这里的全局变量主要是指 非常量全局变量;

尽管在小型项目中,这一点似乎看起来人畜无害,但是在大型项目中往往会出现很多问题。

新手程序员往往比较喜欢使用大量的全局变量,因为这样使用起来方便直接,特别是当设计到不同函数的多次调用传递参数时。

若无特别说明,本文后续内容中提到的全局变量均指 非常量全局变量


全局变量的潜在危险

到目前为止,全局变量危险的最大原因是因为他们的值可以在任何地方被任何调用的函数更改,并且程序员没有简单的方法知道这种情况的发生,考虑下面程序:

 #include <iostream>int g_mode; // 声明全局变量(默认会被初始化为0)void doSomething()
 {
     g_mode = 2; // 将全局变量 g_mode 设置为 2
 }
 ​
 int main()
 {
     g_mode = 1; // 注意:这是将全局变量 g_mode 设置为 1,而不是声明一个局部的 g_mode 变量!
 ​
     doSomething();
 ​
     // 程序员可能仍然期望 g_mode 的值是 1
     // 但是 doSomething 函数已经将其更改为 2!if (g_mode == 1)
     {
         std::cout << "未检测到威胁。\n";
     }
     else
     {
         std::cout << "发射核导弹...\n";
     }
 ​
     return 0;
 }

请注意,程序员将变量g_mode设置为1 ,然后调用doSomething() 。除非程序员明确知道doSomething()将更改g_mode的值,否则他或她可能不会期望doSomething()更改该值!因此, main()的其余部分并不像程序员期望的那样工作,这就导致一枚无情的核弹被发射,恭喜你喜提一份 公牢

简而言之,全局变量使得程序的状态不可预测,每一个函数调用都具有潜在的危险,并且程序员没有简单的方法来查看和防范这些危险。那么,有什么理由不使用局部变量呢?

除此之外,还有很多其他不推荐使用全局变量的充分理由。

对于全局变量,下面的代码并不罕见:

 void someFunction()
 {
     if (g_mode == 4)
     {
         // do something good
     }
 }

就短短的几行代码,假设此时你的程序出现错误无法工作,因为全局变量g_mode的值是3而不是4导致。你怎么解决?很好,你首先需要找到名为g_mode的全局变量可能存在的位置并追踪它的赋值等操作,这些工作不仅仅值涉及到你这点代码,很有可能存在于很多毫不相干的代码中!

将局部变量声明为尽可能靠近其使用位置的关键原因之一是,这样做可以最大限度地减少您需要查看以了解变量的作用的代码量。

全局变量处于相反的一端——因为它们可以在任何地方访问,您可能必须查看整个程序才能了解它们的用法。在小项目中,这可能不是问题。但在大项目中,谁说得准呢是吧?

例如,你发现某个全局变量在你的项目中被引用了545次。如果你一开始没有很好的文档跟踪记录,那么你可能必须仔细查看这个变量在每一个地方的使用,以了解它在不同情况下的使用方式、生效条件、影响的逻辑功能等等。

另外,全局变量还会降低程序模块化程度和灵活性 :

  • 当函数依赖全局变量时,函数的行为可能受到外部状态的影响,无法单独测试或理解其逻辑。

    例如,函数可能隐式依赖某个全局变量的值,而这个值又可能在其他地方被改变,导致函数的行为不可预测。

  • 如果函数与全局变量绑定过紧,这个函数就无法在其他场景中复用,除非那些场景中也包含相同的全局变量。


全局变量的初始化问题

静态变量(包括全局变量)的初始化时程序启动的一部分,在main函数执行之前,这分为两个阶段执行:

第一阶段称为静态初始化。静态初始化分为两个步骤:

  • 全局变量带有 constexpr 初始化器(包括字面值)的会被初始化为这些指定的值,这被称为常量初始化

  • 没有初始化器的全局变量会被初始化为零。由于零是一个 constexpr 值,因此零初始化也被认为是静态初始化的一种形式。

第二个阶段被称为动态初始化。这一阶段更为复杂和细致,但其核心是:带有非 constexpr 初始化器的全局变量会在此阶段被初始化

以下是一个非 constexpr 初始化器的示例:

 int init()
 {
     return 5;
 }
 ​
 int g_something{ init() }; // non-constexpr initialization

在单个文件中,对于每个阶段,全局变量通常按定义顺序进行初始化(对于动态初始化阶段,此规则有一些例外)。鉴于此,需要小心,不要让变量依赖于稍后才会初始化的其他变量的初始化值。例如:

 #include <iostream>int initX();  // forward declaration
 int initY();  // forward declarationint 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';
 }

更严重的问题是,静态对象在不同翻译单元之间的初始化顺序是不明确的。

给定两个.cpp文件,任意一个文件都可以首先初始化全局变量。如果其中a.cpp中某个具有静态持续时间的变量使用b.cpp中定义的静态持续时间进行变量初始化,则b.cpp中的变量有50%的几率尚未初始化。


使用全局变量的一些建议

  • 首先,在全局变量名加上g_前缀,或者更好的做法是将他们放在命名空间中,以减少出现命名冲突的可能性:

例如,下面的代码;

 #include <iostream>constexpr double gravity { 9.8 }; 
 ​
 int main()
 {
     std::cout << gravity << '\n'; 
 ​
     return 0;
 }

可以调整为:

 #include <iostream>namespace constants
 {
     constexpr double gravity { 9.8 }; 
 }
 ​
 int main()
 {
     std::cout << constants::gravity << '\n'; are global)
 ​
     return 0;
 }
  • 其次,一个不错的做法是对变量进行“封装化”,而不是允许直接访问全局变量。

确保变量只能从声明文件内访问,例如将其设置为static或者const,然后提供外部访问函数来处理该变量。这些功能可以确保维护正确的使用,例如进行输入验证、范围检测等。

  • 在编写使用全局变量的独立函数时,不要直接在函数体中使用该变量,相反,我们应该将其作为函数参数传递使用。这样一来,如果你的函数需要在某些情况下使用不同的值,只需要修改参数即可,这也是模块化编程的一种良好实践。

例如:

 #include <iostream>namespace constants
 {
     constexpr double gravity { 9.8 };
 }
 ​
 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 };
 }
 ​
 double instantVelocity(int time, double gravity)
 {
     return gravity * time;
 }
 ​
 int main()
 {
     std::cout << instantVelocity(5, constants::gravity) << '\n'; 
 ​
     return 0;
 }

感谢阅读!