靜態與全域變數

除非它們是可平凡解構的 (trivially destructible),否則禁止使用具有靜態儲存期 (static storage duration) 的物件。 簡單來說,這表示該物件的解構函式不執行任何操作,即便考慮其成員與基底類別的解構函式也是如此。 正式地說,這表示該類型沒有使用者自定義的解構函式,也沒有虛擬解構函式 (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}};
}

請注意,參考 (reference) 不是物件,因此不受解構性的限制影響。 不過動態初始化的限制仍然適用。 特別是具有 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) 是一個常數表示式 (constant expresion),而且如果物件是透過建構函式初始化,那麼該建構函式也一定要被標記 constexpr

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

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

我們一律允許常數初始化。 靜態儲存期的變數若使用常數初始化的話,應標上 constexprconstinit。 任何沒有依照這種方式標記的非區域靜態儲存期變數應該要假定是使用動態初始化,並且在審查時要特別小心。

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

// 一些宣告
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 來初始化就好

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

常見模式

  • 全域字串: 如果需要一個具名的全域或者靜態字串,請考慮使用右列的 constexpr 變數:string_view (在 Abseil 函式庫中)、字元陣列、或是指向字串字面值 (string literal) 的字元指標。 字串字面值本來就有靜態儲存期,而且通常就夠用。 詳情請參考 TotW #140
  • Map、Set、以及其他動態容器: 如果你要求使用靜態的固定集合,像是一個 Set 或是一個查詢表,你不能使用標準函式庫的動態容器作為靜態變數,因為他們具有非平凡的解構函式。 反之,請考慮使用一個包含平凡型別的簡單陣列,例如使用一個整數的陣列的陣列來表示一個從整數映射到另一組整數的關係,或是一個整數與字元指標的 Pair 陣列表示整數映射到字串的關係。 對於小的資料集,線性搜尋就很夠了 (而且因為記憶體局部性 (memory locality) 的關係還很有效率)。 可以考慮使用 absl/algorithm/container.h 來對他們進行一些標準操作。 如果必要的話,保持集合的排序,以便使用二元搜尋 (binary search)。 如果你真的比較想使用標準函式庫的動態容器,請考慮使用函式內的靜態指標,詳情見下文。
  • 智慧指標 (smart pointers): 智慧指標會在解構時執行清理動作,因此被禁止使用。 請考慮你的使用狀況是否符合這章節中描述的其他模式。 一種簡單的解法是對動態分配的物件使用一般指標,然後永遠不要刪除它 (請看最後一項)。
  • 自製型別的靜態變數: 如果你需要對你自己設計的型別使用靜態的常數資料,請確保該型別擁有平凡的解構函式與 constexpr 建構函式。
  • 如果這些方法都不能用,你可以動態建立一個物件,並永遠都不要刪除它 (例如: static const auto& impl = *new T(args...);)。