章节目录

为什么(非常量)全局变量是邪恶的

本节阅读量:

如果您想问一位资深程序员关于良好编程实践的一条建议,经过一番思考,最可能的答案是“避免全局变量!”。有很好的理由:全局变量是历史上被滥用最多的概念之一。尽管它们在小型项目中似乎无害,但在大型项目中通常会有问题。

新手程序员经常使用许多全局变量,因为它们很容易使用,特别是当涉及到对不同函数的许多调用时(通过函数参数传递数据是一件痛苦的事情)。当然这通常是一个坏主意。许多开发人员认为应该完全避免非常量的全局变量!

但在我们讨论原因之前,我们应该澄清一下。当开发人员告诉您全局变量是邪恶的时候,通常不是在谈论所有的全局变量。主要讨论的是非常量的全局变量。


为什么(非常量)全局变量是邪恶的

到目前为止,非常量全局变量是危险的最大原因是,它们的值可以被调用的任何函数更改,并且程序员没有简单的方法得知这发生在哪里。考虑以下程序:

 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
#include <iostream>

int g_mode; // 声明全局变量 (默认初始化为0)

void doSomething()
{
    g_mode = 2; // 全局变量 g_mode设置为 2
}

int main()
{
    g_mode = 1; // 全局变量 g_mode设置为 1

    doSomething();

    // 有很大可能阅读代码的人,认为 g_mode 是 1
    // 但 doSomething 将它修改成 2!

    if (g_mode == 1)
    {
        std::cout << "No threat detected.\n";
    }
    else
    {
        std::cout << "Launching nuclear missiles...\n";
    }

    return 0;
}

请注意,main函数将变量g_mode设置为1,然后调用doSomething() 。除非明确知道 doSomething() 的实现细节,否则阅读代码的读者不知道它修改了变量g_mode的值!因此,main() 的其余部分不能像预期的那样工作。

简而言之,全局变量使程序的状态不可预测。每个函数调用都有潜在的危险,没有简单的方法知道哪些函数调用是危险的,哪些不是!局部变量更安全,因为其他函数不能直接影响它们。

还有许多其他的理由不使用非常量全局变量。

对于全局变量,通常会发现如下所示的代码:

1
2
3
4
5
6
7
8
9
void someFunction()
{
    // 一些其它代码

    if (g_mode == 4)
    {
        // 根据mode不同执行的代码
    }
}

调试后,您确定程序工作不正常,因为g_mode的值为3,而不是4。怎么修理它?现在您需要找到所有可能将g_mode设置为3的地方,并跟踪它是如何设置的。这可能是在一段完全不相关的代码中!

声明局部变量的一个原则,是尽可能靠近它们的使用位置,因为这样做可以最大限度地减少,您为了查看变量作用所需要查看的代码量。全局变量在频谱的另一端——因为它们可以在任何地方访问,所以您可能必须查看整个程序才能理解它们的用法。在小型程序中,这可能不是问题。但在大型程序中,这样极大提高排查问题难度。

例如,您可能会发现在程序中引用了442次g_mode。除非g_mode有很好的文档记录,否则您可能必须仔细检查g_mode的每个相关代码,以了解它在不同的情况下是如何使用的,它的有效值是什么,以及它的整体功能是什么。

全局变量也会降低程序的模块化程度和灵活性。只使用其参数并且没有副作用的函数是完全模块化的。模块化有助于理解程序的功能以及可重用性。全局变量显著降低了模块性。

特别是,避免将全局变量用于重要的“决策点”变量(例如,您将在条件语句中使用的变量,如上例中的变量g_mode)。如果变量值发生了变更,这可能会影响程序的关键行为。


全局变量的初始化顺序问题

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

第一阶段称为静态初始化(static initialization)。在静态初始化阶段,具有constexpr初始值设定项(包括字面值)的全局变量被初始化为对应的值。此外,没有初始值设定项的全局变量被零初始化。

第二个阶段称为动态初始化(dynamic initialization)。这个阶段更加复杂和微妙,但其要点是初始化具有非constexpr初始值设定项的全局变量。

下面是非constexpr初始值设定项的示例:

1
2
3
4
5
6
int init()
{
    return 5;
}

int g_something{ init() }; // 非 constexpr 初始化

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

int initX();  // 前向声明
int initY();  // 前向声明

int g_x{ initX() }; // g_x 被首先初始化
int g_y{ initY() };

int initX()
{
    return g_y; // g_y 这是还没被初始化
}

int initY()
{
    return 5;
}

int main()
{
    std::cout << g_x << ' ' << g_y << '\n';
}

这将打印:

1
0 5

更重要的是,C++没有定义不同文件之间的初始化顺序。给定两个文件a.cpp和b.cpp,任何一个都可以首先初始化其全局变量。这意味着,如果a.cpp中的变量依赖于b.cpp中的值,则这些变量尚未初始化的可能性为50%。


那么,使用非常量全局变量的必须的理由是什么呢?

没有太多必须的理由。在大多数情况下,有其他方法可以解决问题,从而避免使用非常量全局变量。但在某些情况下,明智地使用非常量全局变量实际上可以降低程序的复杂性,并且在这些罕见的情况下,它们的使用可能比替代方案更好。

一个很好的例子是日志文件,您可以在其中打印错误或调试信息。将其定义为全局变量是有意义的,因为您可能在程序中只有一个日志,并且它可能会在程序中的任何地方使用。

值得一提的是,std::cout和std::cin对象被实现为全局变量(在std命名空间内)。

根据经验法则,全局变量的任何使用都应该至少满足以下两个标准:变量在程序中表示的东西应该只有一个,并且它的使用应该在整个程序中无处不在。

许多新程序员错误地认为某些东西可以作为全局实现,因为现在只需要一个。例如,您可能会认为,因为您正在实现单人游戏,所以您只需要一个玩家。但当你想添加多人模式(对战或热区)时,会发生什么呢?


保护自己免受全局变量破坏

如果您确实很好地使用了非常量全局变量,那么一些有用的建议将最大限度地减少您可能遇到的麻烦。这个建议不仅适用于非常量全局变量,而且可以帮助处理所有全局变量。

首先,用“g”或“g_”作为所有非命名空间全局变量的前缀,或者将它们放在命名空间中,以减少命名冲突的可能性。

例如,不编写下面类型的程序:

1
2
3
4
5
6
constexpr double gravity { 9.8 }; // 根据名称无法判断是否全局变量

int main()
{
    return 0;
}

而编写如下的:

1
2
3
4
5
6
7
8
9
namespace constants
{
    constexpr double gravity { 9.8 };
}

int main()
{
    return 0;
}

其次,与其允许直接访问全局变量,不如“封装”变量。确保只能从声明变量的文件中访问该变量,例如,通过使变量成为静态或常量,然后提供外部全局“访问函数”来使用该变量。这些功能可以确保保持正确的使用(例如,进行输入验证、范围检查等)。此外,如果您决定更改底层实现(例如,变量修改名称),则只需更新访问函数,而不是修改使用全局变量的每段代码。

例如,不编写下面类型的程序:

1
2
3
4
namespace constants
{
    extern const double gravity { 9.8 }; // 外部链接, 可以被其他文件访问
}

而编写如下的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
namespace constants
{
    constexpr double gravity { 9.8 }; // 内部链接, 只能在本文件访问
}

double getGravity() // 外部链接, 可以被其他文件访问
{
    // 这里后续可以增加需要的处理逻辑
    // 或者该更返回的变量
    return constants::gravity;
} 

第三,当编写使用全局变量的独立函数时,不要在函数体中直接使用变量。请将其作为参数传入。这样,如果函数在某些情况下需要使用不同的值,则可以简单地改变参数。这有助于维护模块化。

例如,不编写下面类型的程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>

namespace constants
{
    constexpr double gravity { 9.8 };
}

// 这个函数只能依赖固定的gravity来计算速度
double instantVelocity(int time)
{
    return constants::gravity * time;
}

int main()
{
    std::cout << instantVelocity(5) << '\n';

    return 0;

}

而编写如下的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

namespace constants
{
    constexpr double gravity { 9.8 };
}

// 这个函数可以根据不同的gravity,来计算速度 (更加通用)
double instantVelocity(int time, double gravity)
{
    return gravity * time;
}

int main()
{
    std::cout << instantVelocity(5, constants::gravity) << '\n'; // 将常量作为参数进行传递

    return 0;
}

一个笑话

全局变量的最佳命名前缀是什么?

答案://


7.6 外部链接和变量前向声明

上一节

7.8 在多个文件中共享全局常量(使用内联变量)

下一节