最近在看標準庫裏的type_traits
的時候發現了個有趣的地方,幾乎所有在標準庫裏的變量模板都是inline的!
不僅常見的實現上(libstdc++、libc++、ms stl)都是inline的,標準裏給的形式定義也是inline的。
比如微軟開源的stl實現:https://github.com/microsoft/STL/blob/main/stl/inc/type_traits#L73
_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 constexpr
:https://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。
這樣其實就上一節說到的所有問題:
- c++17新加了通常情況下模板變量和inline變量是外部鏈接的規定,因此加上inline解決了模板變量常量鏈接性上的問題
- inline變量允許被多次定義,因此就算ODR規則忘記更新或者重新考慮後改變了規則也沒問題(當然現在已經明確模板變量可以多次定義了)
- 比起加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會有什麼後果:
- 如果你在用c++20或者更新的版本,那麼語法上沒有任何問題;否則在語法上也處於灰色地帶,在參考資料中的第三個鏈接裏就描述了這個原因引起的符號衝突問題
- 各個編譯器處理生成代碼的結果不一樣且不可控,可能會生成和標準描述的不一致的行爲
所以結論顯而易見,有條件的話最好始終給模板變量加上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/65521040/global-variables-and-constexpr-inline-or-not