C++20新增屬性[[no_unique_address]]詳解(最新整理)
有一個(gè)古老的c++問(wèn)題:struct Empty{}; sizeof(Empty); 請(qǐng)問(wèn)Empty的大小是多少。
很多新手會(huì)回答0,但稍有經(jīng)驗(yàn)的開發(fā)者會(huì)說(shuō)出正確答案,大小至少是1字節(jié)。
這看起來(lái)很奇怪,但這是語(yǔ)言規(guī)范決定的:c++要求同一類型的不同實(shí)例對(duì)象必須擁有完全不同的地址,如果Empty的大小是0,那么想象一下一個(gè)元素類型是Empty的數(shù)組,這個(gè)數(shù)組的連續(xù)存儲(chǔ)空間里很可能不同的Empty會(huì)重疊在一起,從而導(dǎo)致它們違反前面對(duì)于擁有不同地址的規(guī)定。最簡(jiǎn)單最省事的做法就是讓這種看起來(lái)大小應(yīng)該為0的類型占據(jù)一字節(jié)的內(nèi)存,從而確保每個(gè)實(shí)例都有獨(dú)立的地址。而且語(yǔ)言規(guī)范也是要求這樣去做的,它要求所有零大小的類型除了位域都必須占至少一字節(jié)的內(nèi)存。
這么做當(dāng)然帶來(lái)了很多弊端,所以c++20新增了屬性[[no_unique_address]]來(lái)解決問(wèn)題。
不過(guò)在介紹這個(gè)屬性之前,我們還得回顧一點(diǎn)基礎(chǔ)知識(shí)。
基礎(chǔ)回顧
c++的知識(shí)是一環(huán)套一環(huán)的,所以基礎(chǔ)回顧環(huán)節(jié)少不了。我們需要回顧三個(gè)小知識(shí)點(diǎn):什么是空類型、什么是空基類優(yōu)化、空類型對(duì)內(nèi)存對(duì)齊的影響。
首先回顧的是“空類型是什么”。
空類型,或者用語(yǔ)言規(guī)范里的叫法“zero size”,是指那些符合標(biāo)準(zhǔn)布局的、沒(méi)有虛基類虛函數(shù)、沒(méi)有非靜態(tài)數(shù)據(jù)成員的類型。如果存在繼承關(guān)系,則類型的每一層繼承關(guān)系上涉及的類型也都必須符合前面提到的條件,這樣的類型可以被視作是空類型。union不在此范圍之內(nèi)。
簡(jiǎn)單的說(shuō),下面三個(gè)類都可以被認(rèn)為是空的:
struct A {
static constexpr int i = 0; // 這是靜態(tài)數(shù)據(jù)成員,不影響類型為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的接口。我們可以看到,沒(méi)有非靜態(tài)數(shù)據(jù)成員沒(méi)有虛函數(shù)且基類也符合同樣條件的類型都會(huì)被認(rèn)為是空類型。
概念還是很容易理解的,不過(guò)標(biāo)準(zhǔn)并沒(méi)有把話說(shuō)死,在后面標(biāo)準(zhǔn)緊接著指出任何編譯器覺(jué)得應(yīng)該是空類型的東西也可以算作空類型。換句話說(shuō)除了標(biāo)準(zhǔn)規(guī)定的少數(shù)情況,還有不少類型是否為空是具體平臺(tái)和編譯器共同影響的。
第二個(gè)要回顧的是“空類型對(duì)內(nèi)存對(duì)齊的影響”。在復(fù)習(xí)空基類優(yōu)化之前我們需要知道優(yōu)化的動(dòng)機(jī),而動(dòng)機(jī)來(lái)自于空類型對(duì)內(nèi)存對(duì)齊的影響。
我們現(xiàn)在都知道因?yàn)閏++對(duì)象地址的限制,空類型需要占用至少一字節(jié)的內(nèi)存。這會(huì)讓程序付出代價(jià):
struct Empty {};
struct A {
long number;
Empty e;
};
static_assert(sizeof(A) > sizeof(long));A的大小至少為2個(gè)long類型的大小。為什么呢,因?yàn)閏++有內(nèi)存對(duì)齊的規(guī)則,類的對(duì)齊長(zhǎng)度以所有非靜態(tài)數(shù)據(jù)成員中對(duì)齊長(zhǎng)度最大的為準(zhǔn),這里我們有兩個(gè)非靜態(tài)數(shù)據(jù)成員,number和e,number的長(zhǎng)度是sizeof(long),而它的對(duì)齊長(zhǎng)度要求也是sizeof(long),e的長(zhǎng)度和對(duì)齊要求都是1,sizeof(long)一定大于1,所以最后類型A要求每個(gè)字段都以sizeof(long)為基準(zhǔn)進(jìn)行對(duì)齊,作為最后一個(gè)字段的e,前面的字段number正好有一個(gè)long類型那么長(zhǎng),而自己后面又沒(méi)有其他字段,按對(duì)齊要求這時(shí)候需要在自己后面填充sizeof(long) - 1個(gè)字節(jié)的填充物。最后A的整體大小會(huì)是兩個(gè)long那么大。
實(shí)際上我們用不到Empty占用的內(nèi)存里的內(nèi)容,通常我們使用空類型是為了利用其類方法或者靜態(tài)數(shù)據(jù),但卻要為了這一字節(jié)付出內(nèi)存占用上的代價(jià)。類型變成兩倍大意味著高速緩存里能存下的同類型數(shù)據(jù)至少減少一半,對(duì)于頻繁訪問(wèn)這類數(shù)據(jù)的程序來(lái)說(shuō)這是顯著的性能損失。
c++為了踐行“不支付不必要的運(yùn)行時(shí)代價(jià)”,提出了EBO——空基類優(yōu)化(Empty Base Optimization)這一方案。
空基類優(yōu)化,是指當(dāng)基類為空類型,派生類的第一個(gè)非靜態(tài)數(shù)據(jù)成員的類型和基類不一樣,繼承不是虛擬繼承的時(shí)候,這個(gè)空類型的基類可以不占用任何存儲(chǔ)空間。
舉個(gè)例子,還是前面的A:
struct Empty {};
struct A : Empty {
long number;
};
static_assert(sizeof(A) == sizeof(long))正常情況下基類也需要在派生類的內(nèi)存空間內(nèi)占據(jù)一部分地盤,但因?yàn)榭栈悆?yōu)化,這一字節(jié)的占用就免除了??栈悆?yōu)化也適用于多繼承:
struct Empty1 {};
struct Empty2 {};
struct A : Empty1, Empty2 {
long number;
};
static_assert(sizeof(A) == sizeof(long))通過(guò)繼承,我們也可以復(fù)用作為基類的空類型的靜態(tài)數(shù)據(jù)和類方法,同時(shí)又不用支付存儲(chǔ)的代價(jià)。
對(duì)于不滿足要求的類型,比如第一個(gè)數(shù)據(jù)成員的類型和基類相同,這時(shí)候空基類優(yōu)化就不生效了:
struct Empty {};
struct A: Empty {
Empty e;
};
static_assert(sizeof(A) > sizeof(Empty));A至少有兩個(gè)Empty那么大。因?yàn)樵谝徊糠制脚_(tái)上基類的內(nèi)存是緊挨著派生類的數(shù)據(jù)成員的,如果第一個(gè)數(shù)據(jù)成員的類型和基類相同,那么繼續(xù)應(yīng)用空基類優(yōu)化就會(huì)導(dǎo)致基類和第一個(gè)數(shù)據(jù)成員發(fā)生重疊(基類的大小是0對(duì)其取地址通常會(huì)得到和派生類或者派生類數(shù)據(jù)成員相同的地址),這違反了c++對(duì)于同類型的不同對(duì)象地址必須不同的規(guī)定。
空基類優(yōu)化在標(biāo)準(zhǔn)庫(kù)里用的很多,比如Hasher、各種迭代器以及allocator,都是使用了空基類優(yōu)化來(lái)復(fù)用方法同時(shí)減小存儲(chǔ)負(fù)擔(dān)的。
另外還有一個(gè)比較知名的空基類優(yōu)化應(yīng)用:compressed_pair,這是std::pair的變體,它在元素為空類型的時(shí)候可以不占用額外的內(nèi)存,原理就是利用了空基類優(yōu)化。這種容器常見的第三方c++模板庫(kù)中都有提供,比如boost。
新屬性no_unique_address
空基類優(yōu)化看似解決了問(wèn)題,然而繼承本身會(huì)引來(lái)新的問(wèn)題。
繼承最大的問(wèn)題在于派生類和基類的關(guān)系是is-a,即派生類從分類上是基類的某種延伸或者說(shuō)派生類和基類直接有著相似的結(jié)構(gòu)和操作方法。但如果我們只是想復(fù)用空類型中的方法或者干脆為了避免內(nèi)存占用而使用空基類優(yōu)化,則會(huì)打破這種is-a關(guān)系。
考慮一下上一節(jié)說(shuō)到的compressed_pair,再能利用no_unique_address之前它的實(shí)現(xiàn)是這樣的:
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是元素的包裝器,用來(lái)提供元素的訪問(wèn)方法,以及在元素大小是0的時(shí)候讓自己的大小也為0,方便利用空基類優(yōu)化:
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_() {}
...
其他一些構(gòu)造函數(shù),這里省略
_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_;
};
// 注意下面這個(gè)為了對(duì)象大小是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() {}
其他一些構(gòu)造函數(shù),這里省略
_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; }
// 注意這里,沒(méi)有任何數(shù)據(jù)成員,所以這個(gè)模板類的實(shí)例大小也是零,這個(gè)模板實(shí)例化出來(lái)的都是空類型
};對(duì)于這些代碼,最直觀的感受就是長(zhǎng)。對(duì)于模板用的不多的開發(fā)者來(lái)說(shuō)這東西還會(huì)沾點(diǎn)難懂。但最重要的問(wèn)題在于這一繼承關(guān)系闡述了這樣一個(gè)情況:pair是(is-a)一種pair自己的元素。很荒誕,
鑒于利用空基類優(yōu)化的代碼又長(zhǎng)又復(fù)雜,還會(huì)違背繼承關(guān)系的原則,c++20接受了[[no_unique_address]]的提案,提供了一種不利用繼承同時(shí)又能讓不同類型的實(shí)例對(duì)象內(nèi)存空間發(fā)生折疊的技術(shù)。
顧名思義,被[[no_unique_address]]修飾的東西可以沒(méi)有自己獨(dú)立的地址。具體來(lái)說(shuō)這個(gè)屬性只能用在類的非靜態(tài)數(shù)據(jù)成員上,且根據(jù)字段是否是空類型會(huì)有不同的效果:
- 如果是空類型,則這個(gè)字段可以和其他的類非靜態(tài)數(shù)據(jù)成員或者基類的內(nèi)存空間重疊在一起,也就是這個(gè)字段本身不再占用內(nèi)存,對(duì)這個(gè)字段取地址也會(huì)得到類的其他數(shù)據(jù)成員或者基類的地址。
- 如果不為空,則這個(gè)字段后面因?yàn)閮?nèi)存對(duì)齊留下的空間可以被其他類成員利用。
對(duì)于非空類型來(lái)說(shuō),這個(gè)屬性沒(méi)有什么明顯的效果,因?yàn)槟壳爸灰噜彽淖侄未笮『蛯?duì)齊合適,就會(huì)自動(dòng)利用前一個(gè)字段因?yàn)閷?duì)齊而留下的空間。這個(gè)屬性只是有限度的放寬了“相鄰”這個(gè)限制,但類的成員還有offset偏移量這個(gè)限制需要遵守,所以很難在非空類型字段上看到這個(gè)屬性帶來(lái)的影響。
而對(duì)于空類型,這個(gè)屬性的影響就大了,舉個(gè)例子:
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,如果不加屬性這個(gè)值會(huì)是4或8
}利用[[no_unique_address]],我們可以讓e和number共享內(nèi)存空間,e不再占用1字節(jié)的額外內(nèi)存,所以A只有一個(gè)long那么大。這是對(duì)于內(nèi)存占用的影響。
第二個(gè)影響是對(duì)[[no_unique_address]]修飾的成員取地址和計(jì)算偏移量。被修飾的字段的地址和偏移量是不確定的。標(biāo)準(zhǔn)規(guī)定對(duì)于被修飾的成員,取地址和計(jì)算偏移量都是合法的,但沒(méi)規(guī)定取到的地址和偏移量具體應(yīng)該是什么,只是說(shuō)可能是其他類成員變量或者基類的地址。換個(gè)說(shuō)法,標(biāo)準(zhǔn)的意思就是取地址是合法的,但得到的值是不確定的。這是一種ABI變更,不僅A的大小改變了,A的成員的內(nèi)存布局也發(fā)生了很大的變化。
[[no_unique_address]]雖然讓被修飾字段的內(nèi)存可以和其他對(duì)象重疊,但仍然需要遵守c++關(guān)于相同類型的不同對(duì)象需要有不同地址的規(guī)定:
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類型相同,為了不違反規(guī)則,e1和e2中有一個(gè)是要有自己的獨(dú)立的內(nèi)存空間的,另一個(gè)可以和其他類型的字重疊。至于那個(gè)字段有獨(dú)立空間哪個(gè)字段重疊,這個(gè)完全由編譯器決定。而類型不同,則兩個(gè)字段都可以和別的字段發(fā)生重疊,因此都不占額外的內(nèi)存空間。
最后一點(diǎn),如果類中只有一個(gè)非靜態(tài)數(shù)據(jù)成員,且這個(gè)成員有空類型,那么[[no_unique_address]]也不會(huì)生效:
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]]提供了一種比空基類優(yōu)化更簡(jiǎn)單更清晰的方式讓空類型不再占用額外的內(nèi)存。
no_unique_address的應(yīng)用
如果你的代碼不是很在意ABI穩(wěn)定性的話,很多空基類優(yōu)化可以轉(zhuǎn)換成更簡(jiǎn)單[[no_unique_address]]。
我們還是拿前文中的libcxx的compressed_pair舉例子,轉(zhuǎn)換后的代碼如下:
struct compressed_pair {
_LIBCPP_NO_UNIQUE_ADDRESS __attribute__((__aligned__(::std::__compressed_pair_alignment<T2>))) T1 Initializer1;
// 內(nèi)存對(duì)齊填充
_LIBCPP_NO_UNIQUE_ADDRESS T2 Initializer2;
// 內(nèi)存對(duì)齊填充
};_LIBCPP_NO_UNIQUE_ADDRESS是個(gè)宏,會(huì)被替換成[[no_unique_address]]或者[[msvc::no_unique_address]],因?yàn)樘?hào)稱完全支持c++20的MSVC實(shí)際上沒(méi)有正確實(shí)現(xiàn)[[no_unique_address]]這個(gè)屬性,所以在MSVC上必須使用編譯器自己實(shí)現(xiàn)的效果類似的屬性,包裝代碼在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實(shí)現(xiàn)的[[no_unique_address]]
# define _LIBCPP_NO_UNIQUE_ADDRESS [[__no_unique_address__]]
# endif整體代碼要比利用空基類優(yōu)化的那版簡(jiǎn)單很多。同時(shí),這個(gè)實(shí)現(xiàn)也不會(huì)有奇怪的繼承關(guān)系了。
除此之外libcxx里還有很多類似的使用例,在不影響運(yùn)行時(shí)效率的前提下大幅簡(jiǎn)化了代碼。
總結(jié)
[[no_unique_address]]讓空類型的類數(shù)據(jù)成員有機(jī)會(huì)不再占用額外的內(nèi)存空間,從而減輕了因?yàn)榈刂芬?guī)定帶來(lái)的性能影響,同時(shí)還讓空基類優(yōu)化代碼得到了簡(jiǎn)化的機(jī)會(huì)。
不過(guò)這個(gè)屬性會(huì)破壞ABI兼容性,所以重構(gòu)的時(shí)候要慎重。然而它帶來(lái)的好處是很實(shí)在的,所以libcxx在去年用這個(gè)屬性重構(gòu)了一大堆的代碼,并且在文檔里注明了哪些東西的ABI兼容被破壞了。對(duì)于開發(fā)者來(lái)說(shuō)這是陣痛,但對(duì)于長(zhǎng)期維護(hù)來(lái)說(shuō)是利大于弊的。
關(guān)于這個(gè)屬性以及對(duì)于c++語(yǔ)言規(guī)范的影響,可以看這里:https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2018/p0840r2.html
到此這篇關(guān)于C++20新增屬性[[no_unique_address]]詳解的文章就介紹到這了,更多相關(guān)C++20新增屬性[[no_unique_address]]詳解內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
C語(yǔ)言詳細(xì)圖解浮點(diǎn)型數(shù)據(jù)的存儲(chǔ)實(shí)現(xiàn)
使用編程語(yǔ)言進(jìn)行編程時(shí),需要用到各種變量來(lái)存儲(chǔ)各種信息。變量保留的是它所存儲(chǔ)的值的內(nèi)存位置。這意味著,當(dāng)您創(chuàng)建一個(gè)變量時(shí),就會(huì)在內(nèi)存中保留一些空間。您可能需要存儲(chǔ)各種數(shù)據(jù)類型的信息,操作系統(tǒng)會(huì)根據(jù)變量的數(shù)據(jù)類型,來(lái)分配內(nèi)存和決定在保留內(nèi)存中存儲(chǔ)什么2022-05-05
詳解dll動(dòng)態(tài)庫(kù)的開發(fā)與調(diào)用及文件的讀寫小程序
這篇文章主要介紹了詳解dll動(dòng)態(tài)庫(kù)的開發(fā)與調(diào)用及文件的讀寫小程序的相關(guān)資料,希望通過(guò)本文能幫助到大家,需要的朋友可以參考下2017-09-09
深入解析C++的循環(huán)鏈表與雙向鏈表設(shè)計(jì)的API實(shí)現(xiàn)
這篇文章主要介紹了C++的循環(huán)鏈表與雙向鏈表設(shè)計(jì)的API實(shí)現(xiàn),文中的示例對(duì)于鏈表結(jié)點(diǎn)的操作起到了很好的說(shuō)明作用,需要的朋友可以參考下2016-03-03
vs運(yùn)行時(shí)報(bào)C4996代碼錯(cuò)誤的問(wèn)題解決
C4996錯(cuò)誤的意思:是VS覺(jué)得strcpy這函數(shù)不安全,建議你使更安全的函數(shù),那么如何解決呢,本文主要介紹了vs運(yùn)行時(shí)報(bào)C4996代碼錯(cuò)誤的問(wèn)題解決,感興趣的可以了解一下2024-01-01
C&C++設(shè)計(jì)風(fēng)格選擇 命名規(guī)范
本文難免帶有主觀選擇傾向,但是會(huì)盡量保持客觀的態(tài)度歸納幾種主流的命名風(fēng)格,僅供參考2018-04-04
vs code 配置c/c++環(huán)境的詳細(xì)教程(推薦)
這篇文章主要介紹了vs code 配置c/c++環(huán)境的詳細(xì)教程(推薦),本文給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2020-11-11

