歡迎來到Linux教程網
Linux教程網
Linux教程網
Linux教程網
您现在的位置: Linux教程網 >> UnixLinux >  >> Linux編程 >> Linux編程

C++ 全局變量初始化學習筆記

注意:本文所說的全局變量指的是 variables with static storage,措詞來自 C++ 的語言標准文檔。

什麼時候初始化

根據 C++ 標准,全局變量的初始化要在 main 函數執行前完成,常識無疑,但是這個說法有點含糊,main 函數執行前到底具體是什麼時候呢?是編譯時還是運行時?答案是既有編譯時,也可能會有運行時(seriously), 從語言的層面來說,全局變量的初始化可以認為分成以下兩個階段(c++11 N3690 3.6.2):

  1. static initialization: 靜態初始化指的是用常量來對變量進行初始化,主要包括 zero initialization 和 const initialization,靜態初始化在程序加載的過程中完成,對簡單類型來說,從具體實現上看,zero initialization 的變量會被保存在 bss 段,const initialization 的變量則放在 data 段內,程序加載即可完成初始化,這和 c 語言裡的全局變量初始化基本是一致的。

  2. dynamic initialization:動態初始化主要是指需要經過函數調用才能完成的初始化,比如說:int a = foo(),或者是復雜類型(類)的初始化(需要調用構造函數)等。這些變量的初始化會在 main 函數執行前由運行時調用相應的代碼從而得以進行。

需要明確的是:靜態初始化執行先於動態初始化!只有當所有靜態初始化執行完畢,動態初始化才會執行。顯然,這樣的設計是很直觀的,能靜態初始化的變量,它的初始值都是在編譯時就能確定,因此可以直接 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]。假設現在需要被別處引用的全局變量為 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"

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

Copyright © Linux教程網 All Rights Reserved