查看原文
其他

【老万】听说 99% 的 C++ 程序员不会用全局变量?

老万 老万故事会
2024-08-23

如果有人告诉你 99% 的 C++ 程序员都不会正确使用全局变量,你信吗?

都知道 C++ 坑多,但不至于这么夸张吧?要是连全局变量这么基础的东西都搞不定,还能当程序员?

你还别说,据我观察,绝大多数大厂 C++ 程序员用全局变量的方法都是错的(至少是不够安全的)。要是把所有 C++ 程序员都算在内,99% 我估计都说低了。

要是不信,看完这篇文章你就知道了。

(阅读以下内容需要 C++ 和面向对象编程的基本知识。)

~~~~

C++ 全局变量的初始化有静态(static initialization)动态(dynamic initialization)之分(参见[1])。简单地说,静态初始化就是一个变量的初始值在编译时就算好了,程序跑起来后拿来就可以用,而动态初始化是指一个变量的初始值是程序运行后再算出来的。

比如:

// 静态初始化:const double radius = 2.5;constexpr char dongbei_homepage[] = "http://dongbei-lang.org/";double circle_area = 3.14159 * radius * radius;constexpr std::pair<int, bool> my_pair(42, true);
// 动态初始化:const char* const home_dir = std::getenv("HOME");const double pi = 4 * atan(1);const std::string google_homepage = "http://google.com/";std::map<int, double> my_map = { {5, 6.5}, {7, 2.3},};

这些静态初始化的例子有个共同特点:这些变量的初始值要么本身就是一个编译时就知道的常数,要么是从这样的常数经过编译器力所能及的简单计算得到的。

动态初始化不一样:它们的初始值计算超出了编译器的能力,所以只能在程序运行时进行。比如,在上面的例子中:

  • home_dir 的值由程序运行时环境变量 HOME 的值决定,在编译时无法预知,只能动态初始化。

  • pi 的计算用到了反正切函数 atan()。虽然从理论上说我们可以教会编译器做 atan() 计算,但目前 atan() 的实现还不能被 C++ 编译器执行,所以也只能在程序运行时做。

  • google_homepage 是一个 std::string 类型的变量。虽然它的内容在编译时就知道,但是 std::string构造函数(constructor)需要做一系列操作,比如在堆(heap)上面分配空间来装字符串“http://google.com/”。访问堆这样的操作是编译器做不到的,所以 google_homepage 也只能动态初始化。

  • 因为类似的原因,my_map 也需要动态初始化。


有人可能会质疑:为什么同样是字符串,google_homepage 不能静态初始化,而 dongbei_homepage 就行?原因是 dongbei_homepage 的类型准确地说是 constexpr char[](字符数组常量),它的值放在编译后二进制代码的数据区(data segment)而不是在堆上。在数据区预留初始数据是编译器的常规操作,可以静态完成。

如果你想完全搞清楚哪些情况下会静态初始化哪些情况下会动态初始化,那就需要掌握回字的四种写法一堆复杂的冷知识,在这里就不展开了。

~~~~

说了半天,为什么我们要关心一个全局变量是被静态还是动态初始化的?

因为动态初始化有一个巨坑。

如果你在同一个 .cpp 文件里定义了一批动态初始化的全局变量,那么它们会按照定义的先后被依次初始化。这里不存在任何不确定性。

但是,要是两个 .cpp 文件都定义了一些动态初始化的全局变量,这两组变量的初始化顺序是不确定的。比如我们有三个文件:

// foo.hextern const std::string foo;
// foo.cpp#include "foo.h"const std::string foo = "blah";
// bar.cpp#include "foo.h"const std::string bar = foo + "!";

那么,先初始化 foo 还是 bar 就要看链接器(linker)的心情了。要是 linker 决定先初始化 foo,那么 bar 会得到正确的值 “blah!”。可要是先初始化 bar,就会出现访问未初始化的 foo 这样的 bug。

foo 在初始化之前可不是什么空字符串,而是一堆垃圾。这个 bug 的性质非常严重,因为访问一个还没有初始化的变量是无定义行为(undefined behavior),可能导致程序跑飞,啥蠢事都可能干得出来(比如删用户数据,发表不符合当地法律法规的言论等等)。

这类 bugs 如此常见,它们甚至有自己的主页([2])和一个专门的名字叫 SIOF(static initialization order fiasco,初始化顺序惨败)

~~~~

雪上加霜的是,动态初始化的全局变量在程序退出时也可能造成大 bug。

在 C++ 中,一个对象在死亡的时候会调用它的析构函数(destructor)来释放它持有的资源和处理其它善后事宜。有的对象(比如一个 std::pair<int, bool>)除了自己占用的内存,没有其它任何资源。它们的析构函数什么也不用做,或者说是平凡的(trivial)。这样的对象,只要它占用的那片内存还没被回收,死了跟没死没啥两样,使用这样的死对象也不会有什么不良后果。

不过,要是一个对象的析构函数是不平凡的(non-trivial),析构就可能毁掉这个对象,也就是说析构完了这个对象就不能用了。

全局变量的销毁顺序跟它们的构造顺序相反,先建的后删,后建的先删。因为不同 .cpp 文件里全局变量的构造顺序是不定的,它们的销毁顺序也是不定的。如果某个全局变量 x 的析构函数试图访问另一个已经被析构的全局变量 y,而 y 的析构函数是不平凡的,x 就会访问到一堆辣鸡,程序就可能会跑飞。

这还没完。现在几乎所有的 C++ 程序都是多线程的(multi-threaded)。对它们来说情况更糟。我们知道,全局变量的销毁是在 main() 函数返回时开始的。但是,从 main() 返回并不会导致正在跑的线程自动终止。所以,在全局变量被销毁的同时,线程仍然可以处于活动状态。如果一个线程在此时访问了一个已经被不平凡地析构的全局变量,我们也会得到无定义行为。

好了,我们得到的教训是:C++ 的坑太多了,傻子才用 C++,贸然定义 C++ 全局变量就是自掘坟墓。


~~~~

C++ 还有没有王法?C++ 程序员还有没有活路?

还好,Google 开源的 C++ 风格指南([3])给出了一个避坑大法。只要小心遵循以下原则,我们还是安全的:

  1. 要是一个全局变量是静态初始化的而且它的析构函数是平凡的,可以直接定义这样一个全局变量。比如 std::string_view prompt = "hello";

  2. 否则,我们应该在第一次访问一个全局变量的时候创建它,并且永不删除。


举例说明 #2:如果想定义一个类型为 Foo 的全局变量 x,我们不要写:

Foo x(1, true);

而要写成:

Foo* x() { static auto* const value = new Foo(1, true); return value;}

同时,访问这个全局变量的方式也要从

x.DoSomething();

改成

x()->DoSomething();

C++ 规定:一个函数内部的静态(static)变量是在该函数第一次被调用的时候才被初始化的,而且只会被初始化一次。这就保证了只要我们按上面的模式编程,在变量间有依赖关系的情况下,它们的初始化顺序正好符合依赖关系,被依赖的变量先被初始化,不会出现无定义行为。

比如我们早先讲的有毛病的例子:

// foo.hextern const std::string foo;
// foo.cpp#include "foo.h"const std::string foo = "blah";
// bar.cpp#include "foo.h"const std::string bar = foo + "!";

可以按 Google 建议的模式改写成:

// foo.hconst std::string* foo();
// foo.cpp#include "foo.h"const std::string* foo() { static auto* const value = new std::string("blah"); return value;}
// bar.cpp#include "foo.h"const std::string* bar() { static auto* const value = new std::string(*foo() + "!"); return value;}

要是我们先调用 foo(),自然没有问题。要是反过来先调用 bar(),也没问题,因为 bar() 自己会调用 foo(),导致 foo() 包含的全局变量被创建,然后再使用 *foo() 的值就安全了。

按这个模式创建的全局变量永远也不会被删除(即使在 main() 返回之后)。这就避免了前面说到的在程序结束时访问一个已被销毁的对象的坑。

~~~~

Google 真的建议永远不删除全局变量吗?这难道不会导致内存和资源泄漏?

有这样想法的朋友多虑了。我们讨论的是在程序退出的时候要不要删除这些全局变量。它们占据的堆空间最终会在进程(process)终止时被一股脑释放,那时它们拥有的所有资源都会被返还给操作系统,用不着我们一点一点地还。

~~~~

C++ 如此巨骚,令无数英雄竞折腰。如果一个全局变量都这么难搞,可以想象写出正确的 C++ 代码有多难。好在一大波意在替换 C++ 的语言已经在路上了,如 Rust、Carbon、Cpp2。总有一天,程序员们会有趁手的工具来书写正确、高效、安全的软件,今天的种种伤痛也将成为历史。

参考文献:

  1. 静态初始化和动态初始化:https://en.cppreference.com/w/cpp/language/initialization

  2. Static Initialization Order Fiasco: https://en.cppreference.com/w/cpp/language/siof

  3. Google C++ 风格指南:https://google.github.io/styleguide/cppguide.html#Static_and_Global_Variables

  4. Construct on First Use:https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Construct_On_First_Use

~~~~~~~~~~

猜你会喜欢:

~~~~~~~~~~

关注老万故事会公众号:

本公众号不开赞赏不放广告。如果喜欢这篇文章,欢迎点赞、在看、转发。谢谢大家🙏


继续滑动看下一个
老万故事会
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存