C++20新增屬性[[no_unique_address]]詳解(jie)
有一個古老的c++問題:struct Empty{}; sizeof(Empty); 請問Empty的大小是多少。
很多新手會回答0,但稍有(you)經驗的開發者會說(shuo)出正確(que)答案,大小(xiao)至少是(shi)1字節。
這(zhe)看起來很奇(qi)怪,但這(zhe)是(shi)(shi)語言規范決定(ding)的(de)(de)(de)(de):c++要(yao)(yao)求同(tong)(tong)一(yi)類型的(de)(de)(de)(de)不(bu)(bu)同(tong)(tong)實(shi)例對(dui)象必須擁(yong)有(you)完(wan)全不(bu)(bu)同(tong)(tong)的(de)(de)(de)(de)地址(zhi),如(ru)果Empty的(de)(de)(de)(de)大小(xiao)是(shi)(shi)0,那么想象一(yi)下一(yi)個(ge)元(yuan)素類型是(shi)(shi)Empty的(de)(de)(de)(de)數(shu)組,這(zhe)個(ge)數(shu)組的(de)(de)(de)(de)連續存儲(chu)空間里很可能不(bu)(bu)同(tong)(tong)的(de)(de)(de)(de)Empty會(hui)重疊在(zai)一(yi)起,從(cong)而導致它們違反前(qian)面對(dui)于擁(yong)有(you)不(bu)(bu)同(tong)(tong)地址(zhi)的(de)(de)(de)(de)規定(ding)。最簡單最省事的(de)(de)(de)(de)做法就是(shi)(shi)讓這(zhe)種看起來大小(xiao)應(ying)該為0的(de)(de)(de)(de)類型占據(ju)一(yi)字節的(de)(de)(de)(de)內(nei)存,從(cong)而確保每(mei)個(ge)實(shi)例都(dou)有(you)獨立的(de)(de)(de)(de)地址(zhi)。而且語言規范也是(shi)(shi)要(yao)(yao)求這(zhe)樣去做的(de)(de)(de)(de),它要(yao)(yao)求所有(you)零(ling)大小(xiao)的(de)(de)(de)(de)類型除了位域(yu)都(dou)必須占至(zhi)少(shao)一(yi)字節的(de)(de)(de)(de)內(nei)存。
這么做當然帶來了很多弊端,所以c++20新增了屬性[[no_unique_address]]來解決問題。
不過在介紹這(zhe)個屬性之前,我們還得回顧一點基礎知識。
基礎回顧
c++的知識是(shi)一(yi)環(huan)(huan)套一(yi)環(huan)(huan)的,所(suo)以基礎回顧(gu)環(huan)(huan)節少不了。我(wo)們需要(yao)回顧(gu)三個小(xiao)知識點:什么(me)(me)是(shi)空(kong)類型(xing)、什么(me)(me)是(shi)空(kong)基類優化、空(kong)類型(xing)對內存(cun)對齊的影響。
首(shou)先回顧(gu)的是“空(kong)類型是什么”。
空(kong)類型,或者(zhe)用語言(yan)規(gui)范里的(de)(de)叫法“zero size”,是指那些符(fu)合(he)標準布局的(de)(de)、沒有虛(xu)基(ji)類虛(xu)函數(shu)、沒有非靜態數(shu)據成員的(de)(de)類型。如果(guo)存在繼(ji)承關系,則類型的(de)(de)每一(yi)層繼(ji)承關系上涉及的(de)(de)類型也都必須(xu)符(fu)合(he)前面提到的(de)(de)條件,這樣的(de)(de)類型可以被視作是空(kong)類型。union不在此范圍之內。
簡單的說(shuo),下面三個類(lei)都(dou)可以被認(ren)為是空的:
struct A {
static constexpr int i = 0; // 這是靜態數據成員,不影響類型為zero size
};
struct B {};
struct C: A {}; // 自己和基類都符合要求
int main()
{
static_assert(std::is_empty_v<A>);
static_assert(std::is_empty_v<B>);
static_assert(std::is_empty_v<C>);
}
std::is_empty是(shi)c++11新增的(de)用于判(pan)斷(duan)類型(xing)(xing)是(shi)否是(shi)zero size的(de)接口。我們可以看到,沒有非(fei)靜態數(shu)據成員沒有虛函數(shu)且基類也符合同樣(yang)條(tiao)件的(de)類型(xing)(xing)都(dou)會(hui)被認為(wei)是(shi)空類型(xing)(xing)。
概念還(huan)是很容易理解的(de),不過標準(zhun)并沒有(you)把話說死,在(zai)后面標準(zhun)緊接著指出(chu)任何編(bian)譯器覺得應(ying)該(gai)是空(kong)類型的(de)東西(xi)也可以算作空(kong)類型。換句話說除了標準(zhun)規(gui)定(ding)的(de)少數情況,還(huan)有(you)不少類型是否(fou)為(wei)空(kong)是具體平臺和編(bian)譯器共同影響的(de)。
第二個要(yao)回顧(gu)的(de)是“空類(lei)型對內(nei)存對齊的(de)影響(xiang)”。在復習空基類(lei)優(you)化(hua)之前我們需要(yao)知道(dao)優(you)化(hua)的(de)動(dong)機(ji),而動(dong)機(ji)來自(zi)于空類(lei)型對內(nei)存對齊的(de)影響(xiang)。
我(wo)們(men)現在(zai)都知道因(yin)為c++對象地址的限制,空(kong)類型需(xu)要占用(yong)至少一字節的內存。這會讓(rang)程序付出(chu)代價(jia):
struct Empty {};
struct A {
long number;
Empty e;
};
static_assert(sizeof(A) > sizeof(long));
A的大小至少為2個long類型的大小。為什么呢,因為c++有內存對齊的規則,類的對齊長度以所有非靜態數據成員中對齊長度最大的為準,這里我們有兩個非靜態數據成員,number和e,number的長度是sizeof(long),而它的對齊長度要求也是sizeof(long),e的長度和對齊要求都是1,sizeof(long)一定大于1,所以最后類型A要求每個字段都以sizeof(long)為基準進行對齊,作為最后一個字段的e,前面的字段number正好有一個long類型那么長,而自己后面又沒有其他字段,按對齊要求這時候需要在自己后面填充sizeof(long) - 1個字節的(de)填充(chong)物(wu)。最后A的(de)整體大小會是兩個long那么大。
實際上(shang)我(wo)們用不(bu)到Empty占用的(de)(de)內存里的(de)(de)內容(rong),通常我(wo)們使用空類(lei)型是為了利用其類(lei)方法或者(zhe)靜態數(shu)據,但卻要為了這一(yi)字節付出內存占用上(shang)的(de)(de)代價。類(lei)型變成兩倍大意味著高速(su)緩存里能存下的(de)(de)同(tong)類(lei)型數(shu)據至少減少一(yi)半(ban),對于頻繁訪問這類(lei)數(shu)據的(de)(de)程序來說這是顯著的(de)(de)性能損失。
c++為了踐(jian)行“不支付不必要(yao)的運(yun)行時代價”,提出(chu)了EBO——空基類優(you)化(hua)(Empty Base Optimization)這一方案。
空基(ji)類(lei)(lei)優化,是指(zhi)當基(ji)類(lei)(lei)為空類(lei)(lei)型(xing),派生類(lei)(lei)的(de)(de)第(di)一(yi)個非(fei)靜態數據成員的(de)(de)類(lei)(lei)型(xing)和(he)基(ji)類(lei)(lei)不(bu)(bu)一(yi)樣,繼承不(bu)(bu)是虛擬繼承的(de)(de)時候,這個空類(lei)(lei)型(xing)的(de)(de)基(ji)類(lei)(lei)可以不(bu)(bu)占用任何存儲空間。
舉個例子,還是前面的(de)A:
struct Empty {};
struct A : Empty {
long number;
};
static_assert(sizeof(A) == sizeof(long))
正常情況(kuang)下基(ji)類(lei)也(ye)需要(yao)在(zai)派生類(lei)的內存空間內占據一部分地(di)盤,但(dan)因為空基(ji)類(lei)優(you)化(hua),這一字節的占用(yong)就免除了。空基(ji)類(lei)優(you)化(hua)也(ye)適(shi)用(yong)于(yu)多繼承:
struct Empty1 {};
struct Empty2 {};
struct A : Empty1, Empty2 {
long number;
};
static_assert(sizeof(A) == sizeof(long))
通過繼承(cheng),我們(men)也可以復用(yong)作為基類(lei)(lei)的(de)空(kong)類(lei)(lei)型的(de)靜態(tai)數據和(he)類(lei)(lei)方法,同時又不用(yong)支付存儲的(de)代(dai)價。
對于(yu)不(bu)滿足要求(qiu)的(de)類(lei)型(xing),比如(ru)第一(yi)個數(shu)據成(cheng)員的(de)類(lei)型(xing)和基(ji)類(lei)相(xiang)同,這時(shi)候空(kong)基(ji)類(lei)優化就(jiu)不(bu)生效了:
struct Empty {};
struct A: Empty {
Empty e;
};
static_assert(sizeof(A) > sizeof(Empty));
A至少有(you)兩個(ge)Empty那(nei)么大。因為在一部分平(ping)臺(tai)上基類(lei)的(de)內存是(shi)緊(jin)挨著(zhu)派(pai)生類(lei)的(de)數(shu)(shu)(shu)據成員的(de),如果第一個(ge)數(shu)(shu)(shu)據成員的(de)類(lei)型(xing)和(he)(he)基類(lei)相同,那(nei)么繼續應用(yong)空基類(lei)優化就(jiu)會(hui)導致(zhi)基類(lei)和(he)(he)第一個(ge)數(shu)(shu)(shu)據成員發生重疊(基類(lei)的(de)大小(xiao)是(shi)0對其取(qu)地址通常會(hui)得(de)到和(he)(he)派(pai)生類(lei)或(huo)者派(pai)生類(lei)數(shu)(shu)(shu)據成員相同的(de)地址),這違反(fan)了c++對于同類(lei)型(xing)的(de)不同對象地址必須不同的(de)規定。
空(kong)基類優化在標準庫里(li)用的很多,比(bi)如Hasher、各(ge)種迭代器以及(ji)allocator,都是使(shi)用了空(kong)基類優化來(lai)復用方法同時減小存儲負擔的。
另外還有一個比較知名的空基類優化應用:compressed_pair,這是std::pair的(de)(de)變體,它在元(yuan)素為空(kong)類(lei)型(xing)的(de)(de)時候可以不(bu)占用額外(wai)的(de)(de)內存,原理就是利用了空(kong)基類(lei)優化。這種容器常(chang)見的(de)(de)第三方c++模板庫中(zhong)都(dou)有提供(gong),比如(ru)boost。
新屬性no_unique_address
空基類優化看似解決了問(wen)題,然而繼承本身會(hui)引來新的問(wen)題。
繼承最大的問題在于派生類和基類的關系是is-a,即派生類從分類上是基類的某種延伸或者說派生類和基類直接有著相似的結構和操作方法。但如果我們只是想復用空類型中的方法或者干脆為了避免內存占用而使用空基類優化,則會打破這種is-a關系。
考慮一下上一節說到的compressed_pair,再能利用no_unique_address之前(qian)它的(de)實現是這樣的(de):
template <class _T1, class _T2>
class compressed_pair : private __compressed_pair_elem<_T1, 0>, private __compressed_pair_elem<_T2, 1> {
public:
// NOTE: This static assert should never fire because __compressed_pair
// is *almost never* used in a scenario where it's possible for T1 == T2.
// (The exception is std::function where it is possible that the function
// object and the allocator have the same type).
static_assert(
(!is_same<_T1, _T2>::value),
"__compressed_pair cannot be instantiated when T1 and T2 are the same type; "
"The current implementation is NOT ABI-compatible with the previous implementation for this configuration");
using _Base1 _LIBCPP_NODEBUG = __compressed_pair_elem<_T1, 0>;
using _Base2 _LIBCPP_NODEBUG = __compressed_pair_elem<_T2, 1>;
...
};
__compressed_pair_elem是元(yuan)(yuan)素(su)(su)的(de)包裝器,用來提供元(yuan)(yuan)素(su)(su)的(de)訪問方法,以及(ji)在元(yuan)(yuan)素(su)(su)大小(xiao)是0的(de)時候讓自己的(de)大小(xiao)也(ye)為0,方便利(li)用空基類優化:
template <class _Tp, int _Idx, bool _CanBeEmptyBase = is_empty<_Tp>::value && !__libcpp_is_final<_Tp>::value>
struct __compressed_pair_elem {
using _ParamT = _Tp;
using reference = _Tp&;
using const_reference = const _Tp&;
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR explicit __compressed_pair_elem(__default_init_tag) {}
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR explicit __compressed_pair_elem(__value_init_tag) : __value_() {}
...
其他一些構造函數,這里省略
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX14 reference __get() _NOEXCEPT { return __value_; }
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR const_reference __get() const _NOEXCEPT { return __value_; }
private:
_Tp __value_;
};
// 注意下面這個為了對象大小是0的部分特化模板
template <class _Tp, int _Idx>
struct __compressed_pair_elem<_Tp, _Idx, true> : private _Tp {
using _ParamT = _Tp;
using reference = _Tp&;
using const_reference = const _Tp&;
using __value_type = _Tp;
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR explicit __compressed_pair_elem() = default;
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR explicit __compressed_pair_elem(__default_init_tag) {}
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR explicit __compressed_pair_elem(__value_init_tag) : __value_type() {}
其他一些構造函數,這里省略
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR_SINCE_CXX14 reference __get() _NOEXCEPT { return *this; }
_LIBCPP_HIDE_FROM_ABI _LIBCPP_CONSTEXPR const_reference __get() const _NOEXCEPT { return *this; }
// 注意這里,沒有任何數據成員,所以這個模板類的實例大小也是零,這個模板實例化出來的都是空類型
};
對(dui)于這(zhe)(zhe)(zhe)(zhe)些代(dai)碼,最(zui)(zui)直(zhi)觀(guan)的感(gan)受就是(shi)長。對(dui)于模板(ban)用(yong)的不多的開發(fa)者來(lai)說(shuo)這(zhe)(zhe)(zhe)(zhe)東西還會沾點(dian)難懂。但(dan)最(zui)(zui)重要(yao)的問(wen)題在于這(zhe)(zhe)(zhe)(zhe)一繼承關系(xi)闡述了這(zhe)(zhe)(zhe)(zhe)樣一個情況:pair是(shi)(is-a)一種pair自己的元素。很荒(huang)誕(dan),
鑒于利用空基類優化的代碼又長又復雜,還會違背繼承關系的原則,c++20接受了[[no_unique_address]]的(de)提案,提供了一種不利用(yong)繼(ji)承(cheng)同(tong)時又能讓不同(tong)類型的(de)實例對(dui)象內(nei)存空(kong)間發生折疊(die)的(de)技術。
顧名思義,被[[no_unique_address]]修飾(shi)的(de)東(dong)西可(ke)以(yi)沒有(you)自己獨立的(de)地址。具(ju)體來說這個屬性只能用在類的(de)非靜態數據(ju)成員(yuan)上,且根據(ju)字段是(shi)否是(shi)空類型會有(you)不(bu)同的(de)效果(guo):
- 如果是空類型,則這個字段可以和其他的類非靜態數據成員或者基類的內存空間重疊在一起,也就是這個字段本身不再占用內存,對這個字段取地址也會得到類的其他數據成員或者基類的地址。
- 如果不為空,則這個字段后面因為內存對齊留下的空間可以被其他類成員利用。
對于(yu)非(fei)空(kong)(kong)類型(xing)來(lai)說,這(zhe)個屬(shu)(shu)性(xing)沒有(you)什(shen)么(me)明(ming)顯的(de)效(xiao)果,因(yin)為目(mu)前只要相(xiang)鄰(lin)的(de)字段大(da)小和(he)對齊合適,就會自(zi)動利用前一個字段因(yin)為對齊而留下的(de)空(kong)(kong)間。這(zhe)個屬(shu)(shu)性(xing)只是有(you)限度(du)的(de)放寬了(le)“相(xiang)鄰(lin)”這(zhe)個限制(zhi),但類的(de)成員(yuan)還有(you)offset偏移(yi)量(liang)這(zhe)個限制(zhi)需要遵守,所(suo)以很難在非(fei)空(kong)(kong)類型(xing)字段上看到(dao)這(zhe)個屬(shu)(shu)性(xing)帶來(lai)的(de)影響(xiang)。
而對于(yu)空(kong)類型,這個(ge)屬性的影響就大了,舉個(ge)例子(zi):
struct Empty {};
struct A {
long number;
[[no_unique_address]] Empty e;
};
static_assert(sizeof(A) == sizeof(long));
#include <cstddef>
int main()
{
std::cout << offsetof(A, e) << '\n'; // GCC和Clang上都是0,如果不加屬性這個值會是4或8
}
利用[[no_unique_address]],我們(men)可以讓e和(he)number共享內存(cun)空間,e不再占用1字節的額外內存(cun),所以A只有一(yi)個(ge)long那么大。這是對于內存(cun)占用的影響。
第二個影響是對[[no_unique_address]]修飾的(de)(de)(de)成員取地(di)(di)址(zhi)和計算偏(pian)移(yi)量。被(bei)修飾的(de)(de)(de)字段的(de)(de)(de)地(di)(di)址(zhi)和偏(pian)移(yi)量是(shi)不確(que)定(ding)(ding)的(de)(de)(de)。標準(zhun)(zhun)規(gui)定(ding)(ding)對于被(bei)修飾的(de)(de)(de)成員,取地(di)(di)址(zhi)和計算偏(pian)移(yi)量都是(shi)合(he)法(fa)的(de)(de)(de),但沒規(gui)定(ding)(ding)取到的(de)(de)(de)地(di)(di)址(zhi)和偏(pian)移(yi)量具(ju)體應該是(shi)什么,只(zhi)是(shi)說可能是(shi)其(qi)他類成員變(bian)量或者基類的(de)(de)(de)地(di)(di)址(zhi)。換個(ge)說法(fa),標準(zhun)(zhun)的(de)(de)(de)意思(si)就是(shi)取地(di)(di)址(zhi)是(shi)合(he)法(fa)的(de)(de)(de),但得到的(de)(de)(de)值是(shi)不確(que)定(ding)(ding)的(de)(de)(de)。這(zhe)是(shi)一(yi)種ABI變(bian)更(geng),不僅A的(de)(de)(de)大小改變(bian)了,A的(de)(de)(de)成員的(de)(de)(de)內(nei)存布局(ju)也發生了很大的(de)(de)(de)變(bian)化。
[[no_unique_address]]雖然(ran)讓被修飾(shi)字段的(de)(de)內(nei)存可以和其(qi)他對(dui)象(xiang)重疊,但仍然(ran)需(xu)要遵守c++關(guan)于相(xiang)同類型的(de)(de)不同對(dui)象(xiang)需(xu)要有不同地址的(de)(de)規定(ding):
struct Empty1 {};
struct Empty2 {};
struct A {
long number;
[[no_unique_address]] Empty1 e1;
[[no_unique_address]] Empty2 e2;
};
struct B {
long number;
[[no_unique_address]] Empty1 e1;
[[no_unique_address]] Empty1 e2;
};
static_assert(sizeof(A) == sizeof(long));
static_assert(sizeof(B) > sizeof(long));
注意B中我們的e1和e2類型相同,為了不違反規則,e1和e2中有(you)一個(ge)(ge)(ge)是要有(you)自己的(de)(de)獨立的(de)(de)內存空間(jian)的(de)(de),另一個(ge)(ge)(ge)可以和其他(ta)類型的(de)(de)字重(zhong)(zhong)疊(die)。至于那個(ge)(ge)(ge)字段(duan)(duan)(duan)有(you)獨立空間(jian)哪(na)個(ge)(ge)(ge)字段(duan)(duan)(duan)重(zhong)(zhong)疊(die),這個(ge)(ge)(ge)完全由編譯器決定。而類型不同,則兩(liang)個(ge)(ge)(ge)字段(duan)(duan)(duan)都(dou)可以和別的(de)(de)字段(duan)(duan)(duan)發生重(zhong)(zhong)疊(die),因(yin)此都(dou)不占額(e)外(wai)的(de)(de)內存空間(jian)。
最后一點,如果類中只有一個非靜態數據成員,且這個成員有空類型,那么[[no_unique_address]]也不會生效:
struct Empty {};
struct A {
[[no_unique_address]] Empty e;
};
struct B {
Empty e;
};
static_assert(sizeof(A) == 1);
static_assert(sizeof(A) == sizeof(B));
屬性[[no_unique_address]]提(ti)供了(le)一種(zhong)比空(kong)基類優化更(geng)簡單更(geng)清晰的方式(shi)讓空(kong)類型不再(zai)占用額外的內存。
no_unique_address的應用
如果你的代碼不是很在意ABI穩定性的話,很多空基類優化可以轉換成更簡單[[no_unique_address]]。
我們還是拿前文中的libcxx的compressed_pair舉例子(zi),轉換(huan)后的代碼如下:
struct compressed_pair {
_LIBCPP_NO_UNIQUE_ADDRESS __attribute__((__aligned__(::std::__compressed_pair_alignment<T2>))) T1 Initializer1;
// 內存對齊填充
_LIBCPP_NO_UNIQUE_ADDRESS T2 Initializer2;
// 內存對齊填充
};
_LIBCPP_NO_UNIQUE_ADDRESS是個宏,會被替換成[[no_unique_address]]或者[[msvc::no_unique_address]],因為號稱完全支持c++20的MSVC實際上沒有正確實現[[no_unique_address]]這個屬性,所以在MSVC上必須使用編譯器自己實現的效果類似的屬性,包裝代碼在llvm-project/libcxx/include/__config里:
# if __has_cpp_attribute(msvc::no_unique_address)
// MSVC implements [[no_unique_address]] as a silent no-op currently.
// (If/when MSVC breaks its C++ ABI, it will be changed to work as intended.)
// However, MSVC implements [[msvc::no_unique_address]] which does what
// [[no_unique_address]] is supposed to do, in general.
# define _LIBCPP_NO_UNIQUE_ADDRESS [[msvc::no_unique_address]]
# else
// __no_unique_address__是clang和gcc實現的[[no_unique_address]]
# define _LIBCPP_NO_UNIQUE_ADDRESS [[__no_unique_address__]]
# endif
整體(ti)代(dai)碼要比利用空(kong)基類優化的那版簡單很多(duo)。同時,這個實現也(ye)不(bu)會有奇怪的繼承(cheng)關系(xi)了。
除(chu)此之外libcxx里還有很多類似的使用例,在不影響運行時效率(lv)的前提下大(da)幅簡化了代碼(ma)。
總結
[[no_unique_address]]讓空類型的(de)(de)類數據(ju)成員有(you)機(ji)會不再占用額(e)外的(de)(de)內存空間,從而(er)減輕(qing)了因為地址(zhi)規定(ding)帶來的(de)(de)性能影響(xiang),同(tong)時還讓空基類優化代碼得(de)到(dao)了簡化的(de)(de)機(ji)會。
不過這(zhe)(zhe)個(ge)屬性會破(po)壞ABI兼容(rong)性,所以重構的(de)時候要慎(shen)重。然而它帶來的(de)好(hao)處是(shi)很實(shi)在的(de),所以libcxx在去年用這(zhe)(zhe)個(ge)屬性重構了(le)一大(da)堆的(de)代碼,并(bing)且在文檔里注明了(le)哪些東西的(de)ABI兼容(rong)被破(po)壞了(le)。對于開發者來說(shuo)這(zhe)(zhe)是(shi)陣痛,但對于長期維(wei)護來說(shuo)是(shi)利大(da)于弊的(de)。
關于這(zhe)個屬性以及對于c++語言規范的(de)影響,可(ke)以看(kan)這(zhe)里(li):
