C++20新增屬性[[no_unique_address]]詳解
有一個古老的c++問題:struct Empty{}; sizeof(Empty); 請問Empty的大小是多少。
很多新手會回答0,但稍有經驗的開發者會說出正確答案,大小至少是1字節。
這看起來很奇怪,但這是語言規范決定的:c++要求同一類型的不同實例對象必須擁有完全不同的地址,如果Empty的大小是0,那么想象一下一個元素類型是Empty的數組,這個數組的連續存儲空間里很可能不同的Empty會重疊在一起,從而導致它們違反前面對于擁有不同地址的規定。最簡單最省事的做法就是讓這種看起來大小應該為0的類型占據一字節的內存,從而確保每個實例都有獨立的地址。而且語言規范也是要求這樣去做的,它要求所有零大小的類型除了位域都必須占至少一字節的內存。
這么做當然帶來了很多弊端,所以c++20新增了屬性[[no_unique_address]]來解決問題。
不過在介紹這個屬性之前,我們還得回顧一點基礎知識。
基礎回顧
c++的知識是一環套一環的,所以基礎回顧環節少不了。我們需要回顧三個小知識點:什么是空類型、什么是空基類優化、空類型對內存對齊的影響。
首先回顧的是“空類型是什么”。
空類型,或者用語言規范里的叫法“zero size”,是指那些符合標準布局的、沒有虛基類虛函數、沒有非靜態數據成員的類型。如果存在繼承關系,則類型的每一層繼承關系上涉及的類型也都必須符合前面提到的條件,這樣的類型可以被視作是空類型。union不在此范圍之內。
簡單的說,下面三個類都可以被認為是空的:
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是c++11新增的用于判斷類型是否是zero size的接口。我們可以看到,沒有非靜態數據成員沒有虛函數且基類也符合同樣條件的類型都會被認為是空類型。
概念還是很容易理解的,不過標準并沒有把話說死,在后面標準緊接著指出任何編譯器覺得應該是空類型的東西也可以算作空類型。換句話說除了標準規定的少數情況,還有不少類型是否為空是具體平臺和編譯器共同影響的。
第二個要回顧的是“空類型對內存對齊的影響”。在復習空基類優化之前我們需要知道優化的動機,而動機來自于空類型對內存對齊的影響。
我們現在都知道因為c++對象地址的限制,空類型需要占用至少一字節的內存。這會讓程序付出代價:
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個字節的填充物。最后A的整體大小會是兩個long那么大。
實際上我們用不到Empty占用的內存里的內容,通常我們使用空類型是為了利用其類方法或者靜態數據,但卻要為了這一字節付出內存占用上的代價。類型變成兩倍大意味著高速緩存里能存下的同類型數據至少減少一半,對于頻繁訪問這類數據的程序來說這是顯著的性能損失。
c++為了踐行“不支付不必要的運行時代價”,提出了EBO——空基類優化(Empty Base Optimization)這一方案。
空基類優化,是指當基類為空類型,派生類的第一個非靜態數據成員的類型和基類不一樣,繼承不是虛擬繼承的時候,這個空類型的基類可以不占用任何存儲空間。
舉個例子,還是前面的A:
struct Empty {};
struct A : Empty {
long number;
};
static_assert(sizeof(A) == sizeof(long))
正常情況下基類也需要在派生類的內存空間內占據一部分地盤,但因為空基類優化,這一字節的占用就免除了。空基類優化也適用于多繼承:
struct Empty1 {};
struct Empty2 {};
struct A : Empty1, Empty2 {
long number;
};
static_assert(sizeof(A) == sizeof(long))
通過繼承,我們也可以復用作為基類的空類型的靜態數據和類方法,同時又不用支付存儲的代價。
對于不滿足要求的類型,比如第一個數據成員的類型和基類相同,這時候空基類優化就不生效了:
struct Empty {};
struct A: Empty {
Empty e;
};
static_assert(sizeof(A) > sizeof(Empty));
A至少有兩個Empty那么大。因為在一部分平臺上基類的內存是緊挨著派生類的數據成員的,如果第一個數據成員的類型和基類相同,那么繼續應用空基類優化就會導致基類和第一個數據成員發生重疊(基類的大小是0對其取地址通常會得到和派生類或者派生類數據成員相同的地址),這違反了c++對于同類型的不同對象地址必須不同的規定。
空基類優化在標準庫里用的很多,比如Hasher、各種迭代器以及allocator,都是使用了空基類優化來復用方法同時減小存儲負擔的。
另外還有一個比較知名的空基類優化應用:compressed_pair,這是std::pair的變體,它在元素為空類型的時候可以不占用額外的內存,原理就是利用了空基類優化。這種容器常見的第三方c++模板庫中都有提供,比如boost。
新屬性no_unique_address
空基類優化看似解決了問題,然而繼承本身會引來新的問題。
繼承最大的問題在于派生類和基類的關系是is-a,即派生類從分類上是基類的某種延伸或者說派生類和基類直接有著相似的結構和操作方法。但如果我們只是想復用空類型中的方法或者干脆為了避免內存占用而使用空基類優化,則會打破這種is-a關系。
考慮一下上一節說到的compressed_pair,再能利用no_unique_address之前它的實現是這樣的:
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是元素的包裝器,用來提供元素的訪問方法,以及在元素大小是0的時候讓自己的大小也為0,方便利用空基類優化:
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; }
// 注意這里,沒有任何數據成員,所以這個模板類的實例大小也是零,這個模板實例化出來的都是空類型
};
對于這些代碼,最直觀的感受就是長。對于模板用的不多的開發者來說這東西還會沾點難懂。但最重要的問題在于這一繼承關系闡述了這樣一個情況:pair是(is-a)一種pair自己的元素。很荒誕,
鑒于利用空基類優化的代碼又長又復雜,還會違背繼承關系的原則,c++20接受了[[no_unique_address]]的提案,提供了一種不利用繼承同時又能讓不同類型的實例對象內存空間發生折疊的技術。
顧名思義,被[[no_unique_address]]修飾的東西可以沒有自己獨立的地址。具體來說這個屬性只能用在類的非靜態數據成員上,且根據字段是否是空類型會有不同的效果:
- 如果是空類型,則這個字段可以和其他的類非靜態數據成員或者基類的內存空間重疊在一起,也就是這個字段本身不再占用內存,對這個字段取地址也會得到類的其他數據成員或者基類的地址。
- 如果不為空,則這個字段后面因為內存對齊留下的空間可以被其他類成員利用。
對于非空類型來說,這個屬性沒有什么明顯的效果,因為目前只要相鄰的字段大小和對齊合適,就會自動利用前一個字段因為對齊而留下的空間。這個屬性只是有限度的放寬了“相鄰”這個限制,但類的成員還有offset偏移量這個限制需要遵守,所以很難在非空類型字段上看到這個屬性帶來的影響。
而對于空類型,這個屬性的影響就大了,舉個例子:
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]],我們可以讓e和number共享內存空間,e不再占用1字節的額外內存,所以A只有一個long那么大。這是對于內存占用的影響。
第二個影響是對[[no_unique_address]]修飾的成員取地址和計算偏移量。被修飾的字段的地址和偏移量是不確定的。標準規定對于被修飾的成員,取地址和計算偏移量都是合法的,但沒規定取到的地址和偏移量具體應該是什么,只是說可能是其他類成員變量或者基類的地址。換個說法,標準的意思就是取地址是合法的,但得到的值是不確定的。這是一種ABI變更,不僅A的大小改變了,A的成員的內存布局也發生了很大的變化。
[[no_unique_address]]雖然讓被修飾字段的內存可以和其他對象重疊,但仍然需要遵守c++關于相同類型的不同對象需要有不同地址的規定:
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中有一個是要有自己的獨立的內存空間的,另一個可以和其他類型的字重疊。至于那個字段有獨立空間哪個字段重疊,這個完全由編譯器決定。而類型不同,則兩個字段都可以和別的字段發生重疊,因此都不占額外的內存空間。
最后一點,如果類中只有一個非靜態數據成員,且這個成員有空類型,那么[[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]]提供了一種比空基類優化更簡單更清晰的方式讓空類型不再占用額外的內存。
no_unique_address的應用
如果你的代碼不是很在意ABI穩定性的話,很多空基類優化可以轉換成更簡單[[no_unique_address]]。
我們還是拿前文中的libcxx的compressed_pair舉例子,轉換后的代碼如下:
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
整體代碼要比利用空基類優化的那版簡單很多。同時,這個實現也不會有奇怪的繼承關系了。
除此之外libcxx里還有很多類似的使用例,在不影響運行時效率的前提下大幅簡化了代碼。
總結
[[no_unique_address]]讓空類型的類數據成員有機會不再占用額外的內存空間,從而減輕了因為地址規定帶來的性能影響,同時還讓空基類優化代碼得到了簡化的機會。
不過這個屬性會破壞ABI兼容性,所以重構的時候要慎重。然而它帶來的好處是很實在的,所以libcxx在去年用這個屬性重構了一大堆的代碼,并且在文檔里注明了哪些東西的ABI兼容被破壞了。對于開發者來說這是陣痛,但對于長期維護來說是利大于弊的。
關于這個屬性以及對于c++語言規范的影響,可以看這里:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0840r2.html


浙公網安備 33010602011771號