我已经多次阅读了新的c ++ 20功能no_unique_address
,我希望有人能用比下面的c ++参考示例更好的示例进行解释和说明。
说明适用于在不是位字段的非静态数据成员的声明中声明的名称。
指示此数据成员不必具有与该类的所有其他非静态数据成员不同的地址。这意味着,如果成员具有空类型(例如,无状态分配器),则编译器可以对其进行优化以使其不占用任何空间,就像它是空基一样。如果成员不为空,则其中的任何尾部填充也可以重新用于存储其他数据成员。
#include <iostream> struct Empty {}; // empty class struct X { int i; Empty e;}; struct Y { int i; [[no_unique_address]] Empty e;}; struct Z { char c; [[no_unique_address]] Empty e1, e2;}; struct W { char c[2]; [[no_unique_address]] Empty e1, e2;}; int main(){ // e1 and e2 cannot share the same address because they have the // same type, even though they are marked with [[no_unique_address]]. // However, either may share address with c. static_assert(sizeof(Z) >= 2); // e1 and e2 cannot have the same address, but one of them can share with // c[0] and the other with c[1] std::cout << "sizeof(W) == 2 is " << (sizeof(W) == 2) << '\n';}
有人可以向我解释此功能的目的是什么,什么时候应该使用它?
e1和e2不能具有相同的地址,但其中一个可以与c [0]共享,而另一个与c [1]可以解释吗?为什么我们有这种关系?
该功能背后的目的与您引用的内容完全相同:“编译器可以对其进行优化以使其不占用空间”。这需要两件事:
空的对象。
想要具有类型可能为空的非静态数据成员的对象。
第一个非常简单,您使用的引号甚至将其拼写为重要的应用程序。类型的对象std::allocator
不实际存储任何东西。它只是全局::new
和::delete
内存分配器的基于类的接口。不存储任何类型数据(通常通过使用全局资源)的分配器通常称为“无状态分配器”。
需要分配器感知的容器来存储用户提供的分配器的值(默认为该类型的默认构造的分配器)。这意味着容器必须具有该类型的子对象,该子对象由用户提供的分配器值初始化。从理论上讲,该子对象会占用空间。
考虑一下std::vector
。这种类型的常见实现是使用3个指针:一个用于数组的开头,一个用于数组的有用部分的结尾,一个用于数组的已分配块的结尾。在64位编译中,这3个指针需要24个字节的存储空间。
无状态分配器实际上没有要存储的任何数据。但是在C ++中,每个对象的大小至少为1。因此,如果vector
将分配器存储为成员vector<T, Alloc>
,则即使分配器不存储任何内容,每个对象也都必须占用至少32个字节。
常见的解决方法是vector<T, Alloc>
从Alloc
自身派生。原因是不需要基类子对象的大小为1。如果基类没有成员并且没有非空基类,则允许编译器在派生类中优化基类的大小。而不实际占用空间。这称为“空基础优化”(标准布局类型必需)。
因此,如果提供无状态分配器,则vector<T, Alloc>
从此分配器类型继承的实现的大小仍仅为24个字节。
但是有一个问题:您必须继承分配器。这真的很烦人。而且很危险。首先,分配器可以是final
,实际上是标准允许的。其次,分配器的成员可能会干扰vector
的成员。第三,这是人们必须学习的一种习语,这使它成为C ++程序员的民间智慧,而不是任何人都可以使用的明显工具。
因此,尽管继承是一种解决方案,但它不是一个很好的解决方案。
这[[no_unique_address]]
是为了什么。它将允许容器将分配器存储为成员子对象而不是基类。如果分配器为空,[[no_unique_address]]
则将允许编译器使其在类的定义内不占用空间。因此,这样的vector
大小仍可能是24个字节。
e1和e2不能具有相同的地址,但其中一个可以与c [0]共享,而另一个与c [1]可以解释吗?为什么我们有这种关系?
C ++有一个基本规则,即必须遵循其对象布局。我称其为“唯一身份规则”。
对于任何两个对象,必须至少满足以下条件之一:
它们必须具有不同的类型。
它们在内存中必须具有不同的地址。
它们实际上必须是同一对象。
e1
并且e2
不是同一对象,因此违反了#3。它们也共享相同的类型,因此违反了#1。因此,它们必须遵循#2:它们不能具有相同的地址。在这种情况下,由于它们是相同类型的子对象,因此这意味着这种类型的编译器定义的对象布局无法在对象内赋予它们相同的偏移量。
e1
并且c[0]
是不同的对象,因此#3再次失败。但是它们满足#1,因为它们具有不同的类型。因此(根据的规则[[no_unique_address]]
),编译器可以将它们分配给对象内的相同偏移量。这同样适用于e2
和c[1]
。
如果编译器希望将一个类的两个不同成员分配给包含对象内的相同偏移量,则它们必须具有不同的类型(请注意,这是通过其每个子对象的所有递归操作)。因此,如果它们具有相同的类型,则它们必须具有不同的地址。
为了理解[[no_unique_address]]
,让我们看一下unique_ptr
。它具有以下签名:
template<class T, class Deleter = std::default_delete<T>>class unique_ptr;
在此声明中,Deleter
表示一个提供用于删除指针的操作的类型。
我们可以这样实现unique_ptr
:
template<class T, class Deleter>class unique_ptr { T* pointer = nullptr; Deleter deleter; public: // Stuff // ... // Destructor: ~unique_ptr() { // deleter must overload operator() so we can call it like a function // deleter can also be a lambda deleter(pointer); }};
那么这个实现有什么问题呢?我们希望unique_ptr
尽可能轻巧。理想情况下,它的大小应与常规指针完全相同。但是因为有Deleter
成员,所以unqiue_ptr
最终至少要有16个字节:8个指针,然后再另外8个用于存储Deleter
,即使Deleter
为空。
[[no_unique_address]]
解决了这个问题:
template<class T, class Deleter>class unique_ptr { T* pointer = nullptr; // Now, if Deleter is empty it won't take up any space in the class [[no_unique_address]] Deleter deleter; public: // STuff...
尽管其他答案已经很好地解释了,但让我从稍微不同的角度来解释它:
问题的根源在于C ++不允许大小为零的对象(即我们一直有sizeof(obj) > 0
)。
这本质上是C ++标准中非常基本的定义的结果:唯一标识规则(如Nicol Bolas解释的),也来自“对象”的定义为非空字节序列。
但是,这导致编写通用代码时出现令人不快的问题。这在某种程度上是可以预料的,因为在此情况下,特殊情况(->空类型)会受到特殊处理,这与其他情况的系统行为有所不同(->大小以非系统的方式增加)。
效果是:
当使用无状态对象(即没有成员的类/结构)时,会浪费空间
零长度数组是禁止的。
由于编写通用代码时很快就能解决这些问题,因此已经进行了多种缓解尝试。
空基类优化。这解决了1)部分情况
引入std :: array,允许N == 0。解决了2),但仍有问题1)
[no_unique_address]的简介,最后解决了所有剩余情况的1)。至少在用户明确要求时。
也许允许零尺寸的对象是更清洁的解决方案,它可以防止碎片。但是,当您在SO上搜索零尺寸的对象时,您会发现具有不同答案的问题(有时并不令人信服),并很快注意到这是一个有争议的话题。允许大小为零的对象将需要对C ++语言的核心进行更改,并且考虑到C ++语言已经非常复杂的事实,标准委员会很可能会决定采用最小侵入路径,并且刚刚引入了新的属性。
加上上方的其他缓解措施,它最终解决了由于不允许零尺寸物体而引起的所有问题。即使从根本上看,它可能不是最佳解决方案,但它是有效的。