c++ 全局变量初始化

转载来源

注意:本文所说的全局变量指的是 variables with static storage,措词来自 c++ 的语言标准文档。

什么时候初始化

根据 C++ 标准,全局变量的初始化要在 main 函数执行前完成,常识无疑,但是这个说法有点含糊,main 函数执行前到底具体是什么时候呢?是编译时还是运行时?答案是既有编译时,也可能会有运行时(seriously), 从语言的层面来说,全局变量的初始化可以划分为以下两个阶段 ( c++11 N3690 3.6.2 ):

  1. static initialization: 静态初始化指的是用常量来对变量进行初始化,主要包括zero initialization 和 const initialization,静态初始化在程序加载的过程中完成,对简单类型(内建类型,POD等)来说,从具体实现上看,zero initialization 的变量会被保存在 bss 段,const initialization 的变量则放在 data 段内,程序加载即可完成初始化,这和 c 语言里的全局变量初始化基本是一致的。

  2. dynamic initialization:动态初始化主要是指需要经过函数调用才能完成的初始化,比如说:int a = foo(),或者是复杂类型(类)的初始化(需要调用构造函数)等。这些变量的初始化会在 main 函数执行前由运行时调用相应的代码从而得以进行(函数内的 static 变量除外)。

需要明确的是:静态初始化执行先于动态初始化! 只有当所有静态初始化执行完毕,动态初始化才会执行。显然,这样的设计是很直观的,能静态初始化的变量,它的初始值都是在编译时就能确定,因此可以直接 hard code 到生成的代码里,而动态初始化需要在运行时执行相应的动作才能进行,因此,静态初始化先于动态初始化是必然的。

初始化的顺序

对于出现在同一个编译单元内的全局变量来说,它们初始化的顺序与他们声明的顺序是一致的(销毁的顺序则反过来),而对于不同编译单元间的全局变量,c++ 标准并没有明确规定它们之间的初始化(销毁)顺序应该怎样,因此实现上完全由编译器自己决定,一个比较普遍的认识是:不同编译单元间的全局变量的初始化顺序是不固定的,哪怕对同一个编译器,同一份代码来说,任意两次编译的结果都有可能不一样[1]。

因此,一个很自然的问题就是,如果不同编译单元间的全局变量相互引用了怎么办?

当然,最好的解决方法是尽可能的避免这种情况(防治胜于治疗嘛),因为一般来说,如果出现了全局变量引用全局变量的窘况,那多半是程序本身的设计出了问题,此时最应该做的是回头重新思考和修改程序的结构与实现,而不是急着穷尽技巧来给错误的设计打补丁。

几个技巧

好吧,我承认总有那么一些特殊的情况,是需要我们来处理这种在全局变量的初始化函数里竟然引用了别的地方的全局变量的情况,比如说在全局变量的初始化函数里调用了 cout, cerr 等(假设是用来打 log, 注意 cout 是标准库里定义的一个全局变量)[2],那么标准库是怎样保证 cout 在被使用前就被初始化了呢? 有如下几个技巧可以介绍一下。

  1. Construct On First Use
    该做法是把对全局变量的引用改为函数调用,然后把全局变量改为函数内的静态变量:

    1
    2
    3
    4
    5
    int get_global_x()
    {
    static X x;
    return x.Value();
    }

    这个方法可以解决全局变量未初始化就被引用的问题,但还有另一个对称的问题它却没法解决,函数内的静态变量也属于 variables with static storage, 它们析构的顺序在不同的编译单元间也是不确定的,因此上面的方法虽然必然能保证 x 的初始化先于其被使用,但却没法妥善处理,如果 x 析构了 get_global_x() 还被调用这种可能发生的情况。

    一个改进的做法是把静态变量改为如下的静态指针:

    1
    2
    3
    4
    5
    int get_global_x()
    {
    static X* x = new X;
    return x->Value();
    }

    这个改进可以解决前面提到的 x 析构后被调用的问题,但同时却也引入了另一个问题: x 永远都不会析构了,内存泄漏还算小问题或者说不算问题,但如果 x 的析构函数还有事情要做,如写文件清理垃圾什么的,此时如果对象不析构,显然程序的正确性都无法保证。

  2. Nifty counter
    完美一点的解决方案是 Nifty counter, 现在 GCC 采用的就是这个做法[3][7]。假设现在需要被别处引用的全局变量为 x, Nifty counter 的原理是通过头文件引用,在所有需要引用 x 的地方都增加一个 static 全局变量,然后在该 static 变量的构造函数里初始化我们所需要引用的全局变量 x,在其析构函数里再清理 x,示例如下:

    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
    // global.h

    #ifndef _global_h_
    #define _global_h_


    extern X x;

    class initializer
    {
    public:
    initializer()
    {
    if (s_counter_++ == 0) init();
    }

    ~initializer()
    {
    if (--s_counter_ == 0) clean();
    }

    private:
    void init();
    void clean();

    static int s_counter_;
    };

    static initializer s_init_val;

    #endif

    相应的 cpp 文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // global.cpp

    #include "global.h"

    static X x;

    int initializer::s_counter_ = 0;

    void initializer::init()
    {
    new(&x) X;
    }

    void initializer::clean()
    {
    (&x)->~X();
    }

    代码比较直白,所有需要引用 x 的地方都需要引用 global.h 这个头文件,而一旦引入了该头文件,就一定会引入 initializer 类型的一个静态变量 s_init_val, 因此虽然不同编译单元间的初始化顺序不确定,但他们都肯定包含有 s_init_val,因此我们可以在 s_init_val 的构造函数里加入对 x 的初始化操作,只有在第一个 s_init_val 被构造时才初始化 x 变量,这可以通过 initializer 的静态成员变量来实现,因为 s_counter_ 的初始化是静态初始化,能保证在程序加载后就完成了。

    初始化 x 用到了 placement new 的技巧,至于析构,那就是简单粗暴地直接调用析构函数了,这一段代码里的技巧也许有些难看,但都是合法的,当然,同时还有些问题待解决:

    首先,因为 x 是复杂类型的变量,它有自己的构造函数,init() 函数初始化 x 之后,程序初始化 x 所在的编译单元时,x 的构造函数还会被再调用一次,同理 x 析构函数也会被调用两次,这显然很容易引起问题,解决的方法是把 x 改为引用:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    // global.cpp

    #include "global.h"

    // need to ensure memory alignment??
    static char g_dummy[sizeof(X)];

    static X& x = reinterpret_cast<X&>(g_dummy);

    int initializer::s_counter_ = 0;

    void initializer::init()
    {
    new(&x) X;
    }

    void initializer::clean()
    {
    (&x)->~X();
    }

    其中 static X& x = reinterpret_cast<X&>(g_dummy); 这一行是静态初始化,因为 g_dummy 是编译时就确定了的(引用是简单类型且以常量为初始值),而 x 只是一个强制转化而来的引用,编译器不会生成调用 x 构造函数和析构函数的代码。通过上面的修改,这个方案已经比较完美了,但遗憾的是它也不是 100% 正确的,这个方案能正确工作的前提是:所有引用 x 的地方都会 include 头文件 global.h,但如果某一个全局变量 y 的初始化函数里没有直接引用 x, 而是间接调用了另一个函数 foo,再通过 foo 引用了 x,此时就可能出错了,因为 y 所在的编译单元里可能并没有直接引用 x,因此很有可能就没有 include 头文件 global.h,那么 y 的初始化就很有可能发生在 x 之前。。。

    这个问题在 gcc c++ 的标准库里也没有得到解决,有兴趣的可以看看这个讨论

Nifty Counter 可执行示例

  • 文件 class_a.h

    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
    /* class_a.h */
    #ifndef _class_h_
    #define _clsss_h_
    #include <iostream>

    class A
    {
    public:
    A(int i) :a(i)
    {
    std::cout << "class A construted." << std::endl;
    }
    ~A()
    {
    std::cout << "class A deconstruted." << std::endl;
    }
    int get_value()
    {
    return a;
    }

    private:
    int a;
    };
    #endif
  • 文件 class_a.cpp

    1
    2
    3
    4
    /* class_a.cpp */
    #include "class_a.h"

    /* void */
  • 文件 global.h

    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
    /* global.h */
    #ifndef _global_h_
    #define _global_h_

    #include "class_a.h"
    extern A& x;

    class initializer
    {
    public:
    initializer()
    {
    if (s_counter_++ == 0) init();
    }
    ~initializer()
    {
    if (--s_counter_ == 0) clean();
    }
    private:
    void init();
    void clean();
    static int s_counter_;
    };

    static initializer s_init_val;

    #endif
  • 文件 global.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    /* global.cpp */
    #include "global.h"

    // need to ensure memory alignment??
    static char g_dummy[sizeof(A)];

    A& x = reinterpret_cast<A&>(g_dummy);

    int initializer::s_counter_ = 0;

    void initializer::init()
    {
    new(&x) A(8);
    }

    void initializer::clean()
    {
    (&x)->~A();
    }
  • 文件 main.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    /* main.cpp */
    #include <iostream>
    #include "global.h"

    int main()
    {
    std::cout << "x = " << x.get_value() << std::endl;
    return 0;
    }

[参考:]

  1. What’s the “static initialization order ‘fiasco’ (problem)

  2. Iostream Objects

  3. ios_init.cc

  4. Why are cout, cin, … initialized once and only once, given the scheme below in the header ?

  5. Initialization and Cleanup, Part III

  6. dynamic initialization of variables

  7. globals_io.cc