為什麼標準庫的模板變數都是inline的

2022-11-28 12:02:24

最近在看標準庫裡的type_traits的時候發現了個有趣的地方,幾乎所有在標準庫裡的變數模板都是inline的!

不僅常見的實現上(libstdc++、libc++、ms stl)都是inline的,標準裡給的形式定義也是inline的。

比如微軟開源的stl實現:https://github.com/microsoft/STL/blob/main/stl/inc/type_traits

_EXPORT_STD template <class _Trait>
_INLINE_VAR constexpr bool negation_v = negation<_Trait>::value;

_EXPORT_STD template <class _Ty>
_INLINE_VAR constexpr bool is_void_v = is_same_v<remove_cv_t<_Ty>, void>;

其中_INLINE_VAR這個宏的實現在這裡:

// P0607R0 Inline Variables For The STL
#if _HAS_CXX17
#define _INLINE_VAR inline
#else // _HAS_CXX17
#define _INLINE_VAR
#endif // _HAS_CXX17

可以看到如果編譯器支援c++17的話這些模板變數就是inline的。

為什麼要這樣做呢?如果不使用inline又會要什麼後果呢?帶著這些疑問我們接著往下看。

c++的linkage

首先複習下c++的linkage,國內一般會翻譯成「連結性」。因為篇幅有限,所以我們不關注「無連結」、「語言連結」和「模組連結」,只關注內部連結外部連結這兩個。

內部連結(internal linkage):符號(粗暴得理解成變數,函數,類等等有名字的東西)僅僅在當前編譯單元內部可見,不同編譯單元之間可以存在同名的符號,他們是不同實體。

看個例子:

// value.h
static int a = 1;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of a: " << &a << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &a << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

注意,不要像上面那樣寫程式碼,尤其是把具有內部連結的非常數變數寫在標頭檔案裡。編譯並執行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x564b7892e004
g() address of a: 0x564b7892e01c

可以看到確實有兩個不同的實體存在。內部連結最大的好處在於可以實現一定程度上的隔離,但缺點是要付出生成檔案體積和執行時記憶體上的代價,且不如名稱空間和模組好使。

這個例子可能看不出,因為只有兩個編譯單元用了這個模板變數,所以只浪費了一個size_t的記憶體,在我的機器上是8位元組。但專案裡往往有成百上千甚至上萬個編譯單元,而且使用的模板變數不止一個,那麼浪費的資源就很可觀了。

外部連結(external linkage):符號可以被所以編譯單元看見,且只能被定義一次。

例子:

// value.h
// extern int a = 1; 這麼寫是宣告的同時定義了a,在標頭檔案裡這麼幹會導致a重複定義
extern int a;

// a.cpp
#include "value.h"

int a = 1; // 隨便在哪定義都行

void f() {
    std::cout << "f() address of a: " << &a << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &a << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

編譯並執行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x55f5825f8040
g() address of a: 0x55f5825f8040

可以看到這時候就只有一個實體了。

那麼什麼樣的東西會有內部連結,什麼又有外部連結呢?

內部連結:所有匿名名稱空間裡的東西(哪怕宣告成extern) + 標記成static的變數、變數模板、函數、函數模板 + 不是模板不是inline沒有volatile或extern修飾的常數(const和constexpr)。

外部連結:非static函數、列舉和類天生有外部連結,除非在匿名名稱空間裡 + 排除內部連結規定的之後剩下的所有模板

說了半天,這和標準庫用inline變數有什麼關係嗎?

還真有,因為內部連結最後一條規則那裡的「非模板和非內聯」是c++17才加入的,而模板變數c++14就有了,所以一個很麻煩的問題出現了:

template <typename T>
constexpr bool is_void_t = is_void<T>::value;

在這裡is_void_t按照c++14的規則,可以是內部連結的。這樣有什麼問題?一般來說問題不大,編譯器會盡可能把常數全部優化掉,但在這個常數被ODR-used(比如取地址或者繫結給函數的參照引數),這個常數就沒法直接優化掉了,編譯器只能乖乖地生產兩個is_void_t的範例。而且這個is_void_t必須是常數,否則可以任意修改它的值,以及不是編譯期常數的話沒法在其他的模板裡使用。

另一個問題在於,c++14忘記更新ODR原則的定義,漏了變數模板,雖然g++上變數模板和其他模板一樣可以存在多次定義,但因為標準裡沒給出具體說法所以存在很大的風險。

c++社群最喜歡的一句格言是:「Don't pay for what you don't use.」

所以c++17的一個提案在增加了inline變數之後建議標準庫裡把模板變數和static constexpr都改為inline constexprhttps://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0607r0.html

inline變數

為什麼提案里加上inline就能解決問題了呢?這就要了解下inline變數會帶來什麼了。

inline在c++裡的意義比起內聯,更確切的是允許某個object(這裡不是物件導向那個object)被定義多次。但前提是每個定義都必須是相同的,且在需要這個object的地方必須要能看到它的完整定義,否則是未定義行為

對於這樣允許多次定義的東西,連結器最後會選擇其中一個定義生成真正的實體變數/類/函數。這就是為什麼所以定義都必須一樣的原因。

看例子:

// value.h
// 例子,Size返回sizeof的值
template <typename T>
struct Size {
    static_assert(!std::is_same_v<T, void>, "can not be void");
    static constexpr std::size_t value = sizeof(T);
};

// 注意這裡
template <typename T>
inline constexpr std::size_t size_v = Size<T>::value;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of size_v: " << &size_v<int> << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &size_v<int> << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

編譯並執行:

$ g++ -Wall -Wextra a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x5615acde601c
g() address of a: 0x5615acde601c

只存在一個實體,看符號表的話也只有一個size_v。

這樣其實就上一節說到的所有問題:

  1. c++17新加了通常情況下模板變數和inline變數是外部連結的規定,因此加上inline解決了模板變數常數連結性上的問題
  2. inline變數允許被多次定義,因此就算ODR規則忘記更新或者重新考慮後改變了規則也沒問題(當然現在已經明確模板變數可以多次定義了)
  3. 比起加static,使用inline不會生成多餘的東西

當然這些只是inline變數帶來的附加優點,真正讓c++加入這一特性的原因因為篇幅這裡就不詳細展開了,有興趣可以深入瞭解哦。

constexpr不是隱式inline的嗎

這話只對了一半。

因為constexpr只對函數靜態成員變數產生隱式的inline。

如果你給一個正常的有namespace scope(在檔案作用域或者namespace裡)變數加上constexpr,它只有const和編譯期計算兩個效果。

所以只加constexpr是沒用的。

我不寫inline會有什麼問題嗎

既然新標準補全了ODR規則,那我可以不再給模板變數加上inline嗎?

我們把上上節的例子裡的inline去掉:

// value.h
// 例子,Size返回sizeof的值
template <typename T>
struct Size {
    static_assert(!std::is_same_v<T, void>, "can not be void");
    static constexpr std::size_t value = sizeof(T);
};

// 注意這裡
template <typename T>
constexpr std::size_t size_v = Size<T>::value;

// a.cpp
#include "value.h"

void f() {
    std::cout << "f() address of size_v: " << &size_v<int> << "\n";
}

// b.cpp
#include "value.h"

void g() {
    std::cout << "g() address of a: " << &size_v<int> << "\n";
}

// main.cpp
void f();
void g();

int main() {
    f();
    g();
}

編譯並執行:

$ g++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x55fb0cfeb020
g() address of a: 0x55fb0cfeb010

這時候結果很有意思,g++12.2.0在生成的二進位制上仍然表現的像是產生了內部連結,而clang14.0.5則和標準描述的一致,產生的結果是正常的:

$ clang++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x56184ee30008
g() address of a: 0x56184ee30008

更有意思的在於,如果我把size_v的constexpr去掉,那麼size_v就會表現成正常的外部連結:

// 注意這裡
template <typename T>
std::size_t size_v = Size<T>::value;
$ g++ -Wall -Wextra -std=c++20 a.cpp b.cpp main.cpp
$ ./a.out

f() address of a: 0x5586c90ef038
g() address of a: 0x5586c90ef038

所以看上去g++在判斷是否是內部連結的規則上沒有遵照c++17標準(還記得老版的標準嗎,非inline的constexpr模板變數會被認為具有內部連結),暫時沒有進一步去查證,所以沒法確定這是g++自己的特性還是單純只是bug。

如果指定inline,兩者的結果是一致的。

所以我不加inline會有什麼後果:

  1. 如果你在用c++20或者更新的版本,那麼語法上沒有任何問題;否則在語法上也處於灰色地帶,在參考資料中的第三個連結裡就描述了這個原因引起的符號衝突問題
  2. 各個編譯器處理生成程式碼的結果不一樣且不可控,可能會生成和標準描述的不一致的行為

所以結論顯而易見,有條件的話最好始終給模板變數加上inline。這樣不管你在用c++17還是c++20,編譯器是GCC還是clang,程式的行為都是符合標準的可預期的。

總結

c++是一門很麻煩的語言,為了弄清楚別人為什麼要用某個關鍵字就得大費周折,還需要許多前置知識作為鋪墊。

換回這次的話題,標準庫的實現和標準定義裡給模板變數加inline最大的原因是因為幾個歷史遺留問題和標準自己的疏漏,當然加上去之後也沒什麼壞處。

這也更說明了在c++裡真的沒有什麼銀彈,某個特性需不需要用得結合自己的知識、經驗還有實際情況來決定,別人的例子最多也只能作為一種參考,也許對於他來說合適的對你來說就是不切實際的,依葫蘆畫瓢前得三思。

這也算是c++的黑暗面之一吧。。。

參考資料

https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0607r0.html

https://stackoverflow.com/questions/70801992/are-variable-templates-declared-in-a-header-an-odr-violation

https://stackoverflow.com/questions/65521040/global-variables-and-constexpr-inline-or-not