c++ 全域性變數初始化的一點總結

twoon發表於2015-03-05

注意:本文所說的全域性變數指的是 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 在被使用前就被初始化了呢? 有如下幾個技巧可以介紹一下。

Construct On First Use

該做法是把對全域性變數的引用改為函式呼叫,然後把全域性變數改為函式內的靜態變數:

int get_global_x()
{
   static X x;
   return x.Value();
}

這個方法可以解決全域性變數未初始化就被引用的問題,但還有另一個對稱的問題它卻沒法解決,函式內的靜態變數也屬於 variables with static storage, 它們析構的順序在不同的編譯單元間也是不確定的,因此上面的方法雖然必然能保證 x 的初始化先於其被使用,但卻沒法妥善處理,如果 x 析構了 get_global_x() 還被呼叫這種可能發生的情況。

一個改進的做法是把靜態變數改為如下的靜態指標:

int get_global_x()
{
   static X* x = new X;
   return x->Value();
}

這個改進可以解決前面提到的 x 析構後被呼叫的問題,但同時卻也引入了另一個問題: x 永遠都不會析構了,記憶體洩漏還算小問題或者說不算問題,但如果 x 的解構函式還有事情要做,如寫檔案清理垃圾什麼的,此時如果物件不析構,顯然程式的正確性都無法保證。

Nifty counter.

完美一點的解決方案是 Nifty counter, 現在 GCC 採用的就是這個做法[3][7]。假設現在需要被別處引用的全域性變數為 x, Nifty counter 的原理是通過標頭檔案引用,在所有需要引用 x 的地方都增加一個 static 全域性變數,然後在該 static 變數的建構函式裡初始化我們所需要引用的全域性變數 x,在其解構函式裡再清理 x,示例如下:

// 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 檔案:

// 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 改為引用:

// 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++ 的標準庫裡也沒有得到解決,有興趣的可以看看這個討論

[參考]

[1] http://isocpp.org/wiki/faq/ctors#static-init-order
[2] https://gcc.gnu.org/onlinedocs/libstdc++/manual/io.html#std.io.objects
[3] https://gcc.gnu.org/onlinedocs/libstdc++/libstdc++-html-USERS-3.4/ios__init_8cc-source.html
[4] https://social.msdn.microsoft.com/Forums/vstudio/en-US/637a4c27-3e30-4b88-b36d-b5b720cf0d04/why-are-cout-cin-initialized-once-and-only-once-given-the-scheme-below-in-the-iostream?forum=vclanguage
[5] http://www.petebecker.com/js/js199905.html
[6] http://blogs.msdn.com/b/ce_base/archive/2008/06/02/dynamic-initialization-of-variables.aspx
[7] http://cs.brown.edu/people/jwicks/libstdc++/html_user/globals__io_8cc-source.html

相關文章