C++ 17 Inline Variable

Inline Variable(內嵌變數)是 C++ 17 新增的功能。Inline Variable 與 Inline Function(內嵌函式)相似,能讓我們在多個 Translation Unit(編譯單元)定義同樣的變數[1]。鏈結器(Linker)在處理多個 Object File(目的檔)的時候會再將名字相同的 Inline Variable 合併為一個變數。

本文大綱:

  1. 動機 -- 介紹 C++ 標準新增 Inline Variable 的動機。
  2. Inline Variable -- 介紹 Inline Variable 的語法與注意事項。
  3. 替代方案 -- 介紹 C++ 17 以前 Inline Variable 的替代選項。

動機

因為編譯 C++ 函式庫通常必需處理很瑣碎的細節,所以一些函式庫作者傾向將整個實作都放在標頭檔,使用者只要引用標頭檔就能直接使用函式庫。這類函式庫我們通常稱為 Header-Only Library(標頭檔函式庫)。然而編寫 Header-Only Library 並不是一件簡單的事。其中一個常見的問題就是有 External Linkage(外部鏈結性)的變數。以下僅以兩個情境解釋為什麼需要以一個額外的 .cpp 檔案定義變數。

情境一:在標頭檔定義變數

在 C++ 98/11/14 語言標準下,如果一個變數有 External Linkage(外部鏈結性),該變數原則上就只能定義於一個 Translation Unit。為了滿足這個要求,通常我們就只會在標頭檔(Header File)宣告變數(#1):

situation1/example.h

#ifndef EXAMPLE_H_
#define EXAMPLE_H_

extern int x;  // #1

extern void print();

#endif  // EXAMPLE_H_

然後在一個 .cpp 檔案定義變數(#2):

situation1/example.cpp

#include "example.h"
#include <iostream>

int x = 42;  // #2

void print() {
  std::cout << x << std::endl;
}

假設我們刪去標頭檔內的 extern 關鍵字:

situation1_bad/example.h

#ifndef EXAMPLE_H_
#define EXAMPLE_H_

int x;  // #3

extern void print();

#endif  // EXAMPLE_H_

此時,如果二個(或以上).cpp 檔案引入相同的標頭檔:

situation1_bad/example.cpp

#include "example.h"
#include <iostream>

void print() {
  std::cout << x << std::endl;
}

situation1_bad/main.cpp

#include "example.h"

int main() {
  print();
}

鏈結器會產生鏈結錯誤:

$ g++ example.cpp main.cpp
/tmp/cc68uH4D.o:(.data+0x0): multiple definition of 'x'
/tmp/cc0QdmVR.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status

因為 example.cppmain.cpp 會各自定義一份 x 全域變數。

情境二:常數靜態資料成員

另一個例子是類別的常數靜態資料成員(Constant Static Data Member)

situation2/static_const_example.h

#ifndef STATIC_CONST_EXAMPLE_H_
#define STATIC_CONST_EXAMPLE_H_

class Example {
public:
  static const int x = 42;  // #4
};

#endif  // STATIC_CONST_EXAMPLE_H_

situation2/main.cpp

#include "static_const_example.h"
#include <iostream>

int main() {
  std::cout << Example::x << std::endl;
}

這個例子乍看之下可以編譯並執行:

$ g++ main.cpp
$ ./a.out
42

然而這是因為 Example::x 是常數,編譯器會將 Example::x 代換為 42。因此 main.cpp 沒有真正地使用 Example::x。如果我們將 main.cpp 改寫為 &Example::x(透過取址運算子產生一個 ODR-Use):

situation2_odr/main_odr.cpp

#include "static_const_example.h"
#include <iostream>

int main() {
  std::cout << &Example::x << std::endl;  // modified
}

鏈結器會找不到對應的變數而產生連結錯誤:

$ g++ main_odr.cpp
/tmp/cc7zHlwx.o: In function 'main':
main_odr.cpp:(.text+0x7): undefined reference to 'Example::x'
collect2: error: ld returned 1 exit status

要解決這個問題,我們必明確地在一個 .cpp 檔案裡定義該靜態資料成員:

situation2_odr/static_const_example.cpp

#include "static_const_example.h"

const int Example::x;

如此就能編譯並執行:

$ g++ static_const_example.cpp main.cpp
$ ./a.out
0x55e3ea5089b8

Note

因為 static_const_example.h 已經宣告 Example::x 的初始值,所以 static_const_example.cpp 只需定義 const int Example::x,不需要(也不能)指定初始值。

Inline Variable

Inline Variable(內嵌變數)是以 inline 關鍵字修飾的變數(包含全域變數或靜態資料成員)(#5#6#8#10);以 constexpr 關鍵字修飾的靜態資料成員也是 Inline Variable(#9):

inline int global_variable = 42;               // #5

class Example {
public:
  static inline int data_member_1{42};         // #6
  static int data_member_2;                    // #7
  static inline const int data_member_3 = 42;  // #8

  static constexpr int data_member_4 = 42;     // #9
};

inline int Example::data_member_2 = 42;        // #10

inline 關鍵字修飾的靜態資料成員能在「類別定義(Class Definition)」內直接以「等號(#8#9)」或「大刮號(#6)」初始化。如果不想要直接在類別定義內初始化,則應該在定義靜態資料成員的時候加上 inline 關鍵字(#7#10)。

Inline Variable(內嵌變數)與 Inline Function(內嵌函式)或 Template(樣版)相似。只要 Translation Unit 有使用到該 Inline Variable,編譯器產生的 Object File 就會包含一份完整的定義。然而編譯器會將 Inline Variable 標上特別的記號[2],當鏈結器看到該記號就會將同名的 Inline Variable 再合併為一個變數。這也保證每一個有 External Linkage 的 Inline Variable 都只會有一個位址。

範例

我們用一個簡單的計數器示範 Inline Variable 的使用方式。這個範例由 3 個檔案組成:

counter.h

#ifndef COUNTER_H_
#define COUNTER_H_

#include <stdio.h>

inline int g_counter = 0;

inline void dump() {
  printf("dump: value=%d ptr=%p\n", g_counter, &g_counter);
  g_counter++;
}

#endif  // COUNTER_H_

main.cpp

#include "counter.h"

extern void test();

int main() {
  printf("main: value=%d ptr=%p\n", g_counter, &g_counter);
  dump();
  printf("main: value=%d ptr=%p\n", g_counter, &g_counter);
  test();
  printf("main: value=%d ptr=%p\n", g_counter, &g_counter);
}

test.cpp

#include "counter.h"

void test() {
  printf("test: value=%d ptr=%p\n", g_counter, &g_counter);
  dump();
  printf("test: value=%d ptr=%p\n", g_counter, &g_counter);
}

我們能用以下命令編譯並執行範例程式:

$ g++ main.cpp test.cpp -std=c++17

$ ./a.out
main: value=0 ptr=0x55b821476014
dump: value=0 ptr=0x55b821476014
main: value=1 ptr=0x55b821476014
test: value=1 ptr=0x55b821476014
dump: value=1 ptr=0x55b821476014
test: value=2 ptr=0x55b821476014
main: value=2 ptr=0x55b821476014

本範例的 counter.hg_counter定義為 Inline Variable。因為 test.cppmain.cpp 各自引入 counter.h 所以它們的 Object File 都會有一份 g_counterdump。不過因為鏈結器會再將它們整合為一份,所以在執行期,test.cppmain.cpp 都只會看到同一個 g_counterdump。鏈結器的符號解析結果如下圖所示:

本圖是範例程式經過鏈結器處理之後的符號解析結果。main.o 與 test.o 裡面使用 g_counter 或 dump 的地方都會指向 main.o 裡面的那份。

如果我們將 counter.h 內的 inline int g_counter 改為 static int g_counter,執行結果會變成:

$ g++ main.cpp test.cpp -std=c++17

$ ./a.out
main: value=0 ptr=0x5609714ab014
dump: value=0 ptr=0x5609714ab014
main: value=1 ptr=0x5609714ab014
test: value=0 ptr=0x5609714ab018  # different
dump: value=1 ptr=0x5609714ab014
test: value=0 ptr=0x5609714ab018  # different
main: value=2 ptr=0x5609714ab014

從輸出結果我們可以注意到:鏈結器不會合併 g_counter,所以 test 函式與 main 函式會看到不同的變數。test 函式印出的數值一直都是 0。不過因為 dump 會被合併,所以 testmain 都會呼叫到同一個 dump 函式,而該 dump 函式會修改同一個 g_counter。鏈結器的符號解析結果如下圖所示:

本圖是範例程式(將 g_counter 的 inline 改為 static)經過鏈結器處理之後的符號解析結果。test.o 裡面使用 g_counter 的地方會指向 test.o 裡面的 g_counter 變數,而 dump 會指向 main.o 裡面的 dump 函式。

如果我們也將 inline void dump() 改為 static inline void dump(),執行結果會變成:

$ g++ main.cpp test.cpp -std=c++17

$ ./a.out
main: value=0 ptr=0x5574838ca014
dump: value=0 ptr=0x5574838ca014
main: value=1 ptr=0x5574838ca014
test: value=0 ptr=0x5574838ca018  # different
dump: value=0 ptr=0x5574838ca018  # different
test: value=1 ptr=0x5574838ca018  # different
main: value=1 ptr=0x5574838ca014

從輸出結果我們可以注意到:鏈結器不會合併 g_counterdump,所以 main.cpptest.cpp 各自有一份 g_counterdump。鏈結器的符號解析結果如下圖所示:

本圖是範例程式(將所有的 inline 改為 static)經過鏈結器處理之後的符號解析結果。test.o 裡面使用 g_counter 與 dump 的地方會指向 test.o 裡面的那份。

注意事項:初始化順序

在同一個 Translation Unit 之內,一般的全域變數會在 main 函式開始執行之前依序從上到下初始化。然而 C++ 標準不保證 Inline Variable 與非 Inline Variable 之間的先後順序。Inline Variable 與 Inline Variable 之間的順序也取決於是不是所有 Translation Unit 都以相同的順序定義二者

以下面的程式碼為例:

init_order/example.h

#ifndef EXAMPLE_H_
#define EXAMPLE_H_

#include <stdio.h>

class Example {
public:
  Example(const char *name) {
    printf("Example(this=%p, name=\"%s\")\n", this, name);
  }
};

#endif  // EXAMPLE_H_

init_order/main.cpp

#include "example.h"

Example main0("main0");

inline Example b("b");
inline Example a("a");

Example main1("main1");

int main() {
  printf("main0=%p\n", &main0);
  printf("b=%p\n", &b);
  printf("a=%p\n", &a);
  printf("main1=%p\n", &main1);
}

init_order/a.cpp

#include "example.h"

inline Example a("a");

init_order/b.cpp

#include "example.h"

inline Example b("b");

在這個例子裡面,如果我們編譯 main.cpp,C++ 標準會保證 main0 的初始化先於 main1 的初始化,b 的初始化先於 a 的初始化:

$ g++ main.cpp -std=c++17

$ ./a.out
Example(this=0x56540ceb5011, name="main0")
Example(this=0x56540ceb5013, name="b")
Example(this=0x56540ceb5014, name="a")
Example(this=0x56540ceb5012, name="main1")
...

然而如果我們同時編譯 a.cppb.cpp,C++ 標準就只會保證 main0main1 的順序,而不會保證 ba 兩者的關係。這是因為 a.cpp 只定義變數 ab.cpp 只定義變數 b,兩個 Translation Unit 都沒有記錄 ba 的先後順序。所以 C++ 編譯器或鏈結器可以自行決定初始化順序。

依照我的實驗結果,GCC 編譯器與鏈結器會隨著命令參數的順序而有不同的結果:

$ g++ a.cpp b.cpp main.cpp -std=c++17; ./a.out
Example(this=0x55bae2207011, name="a")
Example(this=0x55bae2207020, name="b")
Example(this=0x55bae2207030, name="main0")
Example(this=0x55bae2207031, name="main1")
...

$ g++ b.cpp a.cpp main.cpp -std=c++17; ./a.out
Example(this=0x5579258f4011, name="b")
Example(this=0x5579258f4020, name="a")
Example(this=0x5579258f4030, name="main0")
Example(this=0x5579258f4031, name="main1")
...

$ g++ main.cpp a.cpp b.cpp -std=c++17; ./a.out
Example(this=0x56138e0cd011, name="main0")
Example(this=0x56138e0cd013, name="b")
Example(this=0x56138e0cd014, name="a")
Example(this=0x56138e0cd012, name="main1")
...

所以如果你很在意兩個 Inline Variable 之間的初始化順序,請務必將你的 Inline Variable 定義於同一個標頭檔,確保所有的 Translation Unit 都有相同的 Inline Variable 順序。

替代方案

如果你有使用 Inline Variable 的需求,然而你必須支援 C++ 98/11/14,你有以下兩個替代方案。

內嵌函式加上靜態區域變數

如果一個有 External Linkage 的 Inline Function 定義了一個 Static Local Variable(屬於 Static Storage 的區域變數),C++ 標準保證所有的 Translation Unit 都會指向同一個變數。所以我們可以宣告一個函式,然後回傳 Static Local Variable 的參考:

inline int &example() {
  static int variable = 42;
  return variable;
}

不過這個方法和 Inline Variable 有二個差異:

  1. 如果 Static Local Variable 的型別是有建構式的類別,其建構式會在第一次執行 Inline Function 的時候被呼叫[3]。視不同使用情境,這可能是優點也可能是缺點。
  2. 在 C++ 98 這個方法不是 Thread-Safe。如果二個執行緒同時呼叫此 Inline Function,初始化 Static Local Variable 的過程可能會有 Data Race。

類別樣版與靜態資料成員

一般而言,鏈結器會合併 C++ 樣版產生的函式或變數,所以我們可以利用這個特性宣告靜態資料成員,然後以 Example<void>::variable 存取該變數:

template <typename T>
class Example {
public:
  static int variable;
};

template <typename T>
int Example<T>::variable = 42;

這個方法和 Inline Variable 的差異在於變數與變數之間的初始化順序。前面有提到 Inline Variable 與 Inline Variable 之間,如果每個 Translation Unit 都有相同的定義順序,C++ 標準會保證初始化順序。然而以類別樣版定義的變數是沒有任何保證的。C++ 編譯器的實作者可以自行決定樣版靜態資料成員之間的初始化順序。

參考資料

  • P0386R2: Inline Variables
  • N4659: Working Draft, Standard for Programming Language C++ (C++ 17 Draft); Section 10.1.6. The inline specifier [dcl.inline]; Section 6.6.3. Dynamic initialization of non-local variables [basic.start.dynamic]
  • Using the GNU Compiler Collection (GCC), Vague Linkage

[1]Translation Unit(編譯單元)是指作為編譯器輸入的完整 C++ 原始碼(包含 #include 引入的程式碼)。C++ 編譯器會把一個 Translation Unit 編譯為一個 Object File(目的檔)。
[2]在常見的目標平台 GCC 會以 COMDAT 記錄 Inline Function、Inline Variable 與 Template Instantiation。如果目標平台不支援 COMDAT,GCC 編譯器會生成 Weak Symbol。
[3]C++ 標準只要求全域 Inline Variable 的初始化時間點可以是「先於 main 函式」或者「第一次使用(ODR-Use)該 Inline Variable 之前」。至於是那一個則是由 C++ 編譯器實作者決定。GCC 是直接在 main 函式之前初始化。

Note

如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。