Inline Variable(內嵌變數)是 C++ 17 新增的功能。Inline Variable 與 Inline Function(內嵌函式)相似,能讓我們在多個 Translation Unit(編譯單元)定義同樣的變數[1]。鏈結器(Linker)在處理多個 Object File(目的檔)的時候會再將名字相同的 Inline Variable 合併為一個變數。
本文大綱:
- 動機 -- 介紹 C++ 標準新增 Inline Variable 的動機。
- Inline Variable -- 介紹 Inline Variable 的語法與注意事項。
- 替代方案 -- 介紹 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
):
#ifndef EXAMPLE_H_
#define EXAMPLE_H_
extern int x; // #1
extern void print();
#endif // EXAMPLE_H_
然後在一個 .cpp 檔案定義變數(#2
):
#include "example.h"
#include <iostream>
int x = 42; // #2
void print() {
std::cout << x << std::endl;
}
假設我們刪去標頭檔內的 extern
關鍵字:
#ifndef EXAMPLE_H_
#define EXAMPLE_H_
int x; // #3
extern void print();
#endif // EXAMPLE_H_
此時,如果二個(或以上).cpp 檔案引入相同的標頭檔:
#include "example.h"
#include <iostream>
void print() {
std::cout << x << std::endl;
}
#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.cpp
與 main.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_
#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):
#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 個檔案組成:
#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_
#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);
}
#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.h
將 g_counter
定義為 Inline Variable。因為 test.cpp
與 main.cpp
各自引入 counter.h
所以它們的 Object File 都會有一份 g_counter
與 dump
。不過因為鏈結器會再將它們整合為一份,所以在執行期,test.cpp
與 main.cpp
都只會看到同一個 g_counter
與 dump
。鏈結器的符號解析結果如下圖所示:
如果我們將 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
會被合併,所以 test
與 main
都會呼叫到同一個 dump
函式,而該 dump
函式會修改同一個 g_counter
。鏈結器的符號解析結果如下圖所示:
如果我們也將 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_counter
與 dump
,所以 main.cpp
與 test.cpp
各自有一份 g_counter
與 dump
。鏈結器的符號解析結果如下圖所示:
注意事項:初始化順序
在同一個 Translation Unit 之內,一般的全域變數會在 main
函式開始執行之前依序從上到下初始化。然而 C++ 標準不保證 Inline Variable 與非 Inline Variable 之間的先後順序。Inline Variable 與 Inline Variable 之間的順序也取決於是不是所有 Translation Unit 都以相同的順序定義二者。
以下面的程式碼為例:
#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_
#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);
}
#include "example.h"
inline Example a("a");
#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.cpp
與 b.cpp
,C++ 標準就只會保證 main0
與 main1
的順序,而不會保證 b
與 a
兩者的關係。這是因為 a.cpp
只定義變數 a
,b.cpp
只定義變數 b
,兩個 Translation Unit 都沒有記錄 b
和 a
的先後順序。所以 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 有二個差異:
- 如果 Static Local Variable 的型別是有建構式的類別,其建構式會在第一次執行 Inline Function 的時候被呼叫[3]。視不同使用情境,這可能是優點也可能是缺點。
- 在 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
如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。