靜態與全域變數

不允許使用有著靜態生存期 (static storage duration) 的物件,除非他們是可平凡解構的 (trivially destructible)。 白話來說,這代表他的解構函式不做任何事情,包括他的成員與基底類別的解構函式在內。 更正式地說,這代表他的型別沒有使用者定義的或是虛擬解構函式 (virtual destructor),而且他的基底類別跟非靜態成員都是可平凡解構的。 函式內的區域變數可以使用動態初始化。 不鼓勵對靜態類別成員變數與位於命名空間作用域的變數使用動態初始化,但是在某些限定的情況下允許。 請看以下說明。

經驗法則:如果一個全域變數的宣告是 constexpr (常數表達式) 的話,他就符合這些要求。

定義

每個物件都有儲存期 (storage duration),這與他的存活期 (lifetime) 相關。 有著靜態儲存期 (static storage duration) 的物件從被初始化到程式結束都會存活。 這種物件出現在命名空間作用域作為一種全域變數、作為類別的靜態資料成員或是函式中帶有 static 字樣的區域變數。 函式區域靜態變數在執行經過他的宣告時被初始化;所有其他有著靜態儲存期的物件則作為程式啟動的一部分而同時被初始化。 所有有著靜態儲存期的物件在程式結束時被摧毀 (發生在所有尚未加入的執行緒終止之前)。

初始化可以是動態的,也就是說在初始化時可以發生一些不平凡 (non-trivial) 的事情。 (例如:一個會分配記憶體的建構函式,或是需要現在程序的 ID 才能初始化的變數。) 其他類的初始化都算是靜態初始化,雖然這兩者並不是完全相反的: 有著靜態儲存期的物件總是會先靜態初始化 (用一個給定的常數或者全部都是零的表示法初始化變數),然後需要的話才會動態初始化。

優點

全域與靜態變數對於大量的應用來說非常好用: 命名的常數、一些轉換元件中的輔助資料結構、命令提示的旗標 (flag)、日誌、註冊機制、背景基礎設施等等。

缺點

全域與靜態變數使用著動態初始化或者不平凡的解構函式產生的複雜度容易導致難以尋找的錯誤。 動態初始化在轉換元件之間或者解構時沒有被排序 (除了那種在反向初始化時的解構動作)。 當一個初始化指向另一個有著靜態儲存期的變數時,有可能會導致一個物件在他的存活期開始前 (或者結束之後) 被存取。 更甚者,當一個程式啟動了一些在結束時還沒加入的執行緒,這些執行緒可能會存取那些已經結束存活期的物件 (如果他的解構函式已經跑完的話)。

我們的決定

對於解構函式

當解構函式是平凡的 (trivial),他的執行完全並不會被順序所影響 (因為他們基本上不會執行);不然的話,我們會暴露在某些物件的存活期結束之後存取他們的風險之中。 因此我們只允許可平凡解構的物件擁有靜態儲存期。 基礎型別 (像是指標或者 int) 都是可平凡解構的,這些型別的陣列也都是。 注意被標示 constexpr 的變數也是可平凡解構的。

const int kNum = 10;  // 允許

struct X { int n; };
const X kX[] = {{1}, {2}, {3}};  // 允許

void foo() {
  static const char* const kMessages[] = {"hello", "world"};  // 允許
}

// 允許: constexpr 保證具備平凡的解構函式
constexpr std::array<int, 3> kArray = {{1, 2, 3}};
// 不好: 不是可平凡解構的
const string kFoo = "foo";

// 雖然 kBar 只是一個參考 (reference),但是因為相同原因所以不好。
// 注意這個規則也適用於延長存活期的暫存物件中。
const string& kBar = StrCat("a", "b", "c");

void bar() {
  // 不好: 非可平凡解構函式
  static std::map<int, int> kData = {{1, 0}, {2, 0}, {3, 0}};
}

注意參考並非物件,所以他們並不會被解構的限制所影響。 不過動態初始化的限制仍然適用。 特別是,一個具有 static T& t = *new T; 形式的函式內區域靜態變數是允許的。

對於初始化

初始化是一個更複雜的主題。 因為我們不只要考慮類別的建構式是否執行,也要考慮初始化時的計算:

int n = 5;    // 可以
int m = f();  // ? (根據 f 的行為決定)
Foo x;        // ? (根據 Foo::Foo 的行為決定)
Bar y = g();  // ? (根據 g 與 Bar::Bar 的行為決定)

除了第一式之外都帶來不確定的初始化順序問題。

我們這邊想要表達的概念在 C++ 標準的正式語言中稱為「常數初始化 (Constant Initialization)」。 這代表初始化的表示式 (expression) 是一個常數表示式,而且如果物件是使用建構函式初始化,那麼該建構函式也一定要標記 constexpr

struct Foo { constexpr Foo(int) {} };

int n = 5;  // 可以,5 是一個常數表示式
Foo x(2);   // 可以,2 是一個常數表示式,而且選擇的建構函式也是 constexpr
Foo a[] = { Foo(1), Foo(2), Foo(3) };  // 可以

我們允許使用常數初始化。 具有靜態儲存期的變數使用常數初始化的話,應該標上 constexpr 或是在可行的地方標上 ABSL_CONST_INIT 屬性。 任何沒有依照這種方式標記的、而且是不是區域變數的靜態儲存期變數,應該要盡可能地使用動態初始化,並且要仔細檢查。

作為反例,以下初始化是有問題的:

// 一些宣告
time_t time(time_t*);      // 沒有使用 constexpr!
int f();                   // 沒有使用 constexpr!
struct Bar { Bar() {} };

// 有問題的初始化
time_t m = time(nullptr);  // 初始化的表示是並非常數表示式
Foo y(f());                // 同上
Bar b;                     // 選用的建構函數 Bar::Bar() 也不是 constexpr

我們不鼓勵對於非區域變數使用動態初始化,而且一般來說也不允許。 然而,如果程式中沒有任何層面依賴該初始化與其他初始化之間的順序的話,我們就允許這麼做。 在這樣的限制下,初始化間的順序並不會造成顯著差異。 例如:

int p = getpid();  // 允許,只要沒有其他靜態變數使用 p 來初始化就好

允許對區域靜態變數使用動態初始化 (而且很常用)。

常見模式

  • 全域字串: 如果你要求使用全域或靜態字串常數,請考慮使用簡單的字元陣列或者一個指向字串字面值 (literal) 之中第一個字的字元指標。 字串字面值已經具有靜態儲存期,通常已足夠使用。
  • Map、Set、以及其他動態容器: 如果你要求使用靜態的、長度固定的資料集,像是一個 Set 或是一個查詢表,你不能把標準函式庫中的動態容器當成靜態變數使用,因為他們具有非平凡的解構函式。 反之,請考慮使用一個包含平凡型別的簡單陣列 (例如想要一個映射 int 到 int 的表,就建一個整數的陣列的陣列),或是一個 pair 的陣列 (例如包含 intconst char* 的 pair)。 對於小的資料集,線性搜尋就很夠了 (而且因為 memory locality 的關係,會很有效率)。 如果必要的話,保持資料集的順序性,並且使用二元搜尋法 (binary search)。 如果你真的比較想使用標準函式庫的動態容器,請考慮使用函式內的區域靜態指標,下面描述。
  • 智慧指標 (smart pointers): 智慧指標會在解構時執行清理動作,因此在上述狀況中被禁止使用。 請考慮你的使用狀況是否符合這章節中描述的其他模式。 一種簡單的解法是對動態分配的物件使用一般指標,然後永遠不要刪除它 (請看最後一項)。
  • 自製型別的靜態變數: 如果你要求對你自己設計的型別使用靜態的常數資料,請給他一個平凡的解構函式以及 constexpr 的建構函數。
  • 如果這些方法都不能用,你可以動態建立一個物件並將該物件綁訂在一個函式內的區域靜態指標變數,並且永遠都不要刪除它:static const auto* const impl = new T(args...); (如果初始化更複雜,你可以把它移入一個函式或者寫成一個 Lambda 運算式。)