c++20 [[no_unique_address]] 中的新特性是什么?

nor*_*nor 50 c++ attributes c++20

我已经多次阅读了新的 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';
}
Run Code Online (Sandbox Code Playgroud)
  1. 有人可以向我解释这个功能背后的目的是什么,我应该什么时候使用它?
  2. e1 和 e2 不能有相同的地址,但其中一个可以与 c[0] 共享,另一个与 c[1] 共享,有人可以解释吗?为什么我们会有这种关系?

Nic*_*las 69

该功能背后的目的与您的引述中所述完全相同:“编译器可能会对其进行优化以使其不占用空间”。这需要两件事:

  1. 一个空的对象。

  2. 想要拥有可能为空类型的非静态数据成员的对象。

第一个非常简单,您使用的引用甚至说明了它是一个重要的应用程序。类型的对象实际上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]]则将允许编译器使其不占用类定义中的空间。所以这样的 avector仍然可以是 24 个字节的大小。


e1 和 e2 不能有相同的地址,但其中一个可以与 c[0] 共享,另一个与 c 1可以有人解释吗?为什么我们会有这种关系?

C++ 有一个基本规则,它的对象布局必须遵循。我称之为“唯一身份规则”。

对于任何两个对象,至少必须满足以下一项:

  1. 他们必须有不同的类型。

  2. 它们在内存中必须有不同的地址

  3. 它们实际上必须是同一个对象。

e1并且e2不是同一个对象,因此违反了#3。它们也共享相同的类型,因此违反了 #1。因此,他们必须遵循#2:他们不能有相同的地址。在这种情况下,由于它们是相同类型的子对象,这意味着该类型的编译器定义的对象布局不能在对象内给它们相同的偏移量。

e1并且c[0]是不同的对象,所以 #3 再次失败。但它们满足#1,因为它们有不同的类型。因此(根据 的规则[[no_unique_address]])编译器可以将它们分配给对象内的相同偏移量。这同样适用于e2c[1]

如果编译器想要将一个类的两个不同成员分配给包含对象内的相同偏移量,那么它们必须是不同的类型(请注意,这是通过它们的所有子对象递归的)。因此,如果它们具有相同的类型,则它们必须具有不同的地址。


J. *_*rez 19

为了理解[[no_unique_address]],让我们来看看unique_ptr。它具有以下签名:

template<class T, class Deleter = std::default_delete<T>>
class unique_ptr;
Run Code Online (Sandbox Code Playgroud)

在此声明中,Deleter表示提供用于删除指针的操作的类型。

我们可以这样实现unique_ptr

template<class T, class Deleter = std::default_delete<T>>
class unique_ptr;
Run Code Online (Sandbox Code Playgroud)

那么这个实现有什么问题呢?我们希望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...
Run Code Online (Sandbox Code Playgroud)

  • @Praetorian如果一个问题可以通过增加复杂性来解决,那么它仍然是一个问题。 (3认同)
  • +1,但这在实践中并不是真正的问题,因为大多数(如果不是全部)“unique_ptr”实现通过将指针和删除器存储在“compressed_pa​​ir”或使用空基优化的类似类型中来避免该问题。当然,正如尼科尔的回答所说,这并不是万无一失的,因为你可能有一个“最终”删除器类型等。 (2认同)
  • @Praetorian:“*这在实践中并不是真正的问题*”为什么不呢?因为普通的 C++ 程序员不允许自己编写“compressed_pa​​ir”?`boost::compressed_pa​​ir` 存在的事实*完全*足以证明这是人们真正的、实际的需求。就像“boost::noncopyable”证明我们需要一种方法来创建不可复制的类型。 (2认同)

And*_* H. 9

虽然其他答案已经很好地解释了它,但让我从一个稍微不同的角度来解释它:

问题的根源在于 C++ 不允许零大小的对象(即我们总是有sizeof(obj) > 0)。

这本质上是 C++ 标准中非常基本的定义的结果:唯一标识规则(如 Nicol Bolas 所解释的)以及“对象”作为非空字节序列的定义。

然而,这在编写通用代码时会导致令人不快的问题。这在某种程度上是可以预料的,因为这里的边角案例(-> 空类型)接受了特殊处理,这与其他案例的系统行为不同(-> 大小以非系统方式增加)。

效果如下:

  1. 当使用无状态对象(即没有成员的类/结构)时,空间被浪费了
  2. 禁止零长度数组。

由于在编写通用代码时会很快解决这些问题,因此已经进行了多次缓解尝试

  • 空基类优化。这解决了 1) 对于案例的子集
  • 引入允许 N==0 的 std::array。这解决了 2) 但仍然有问题 1)
  • [no_unique_address] 的引入,最终解决了所有剩余情况的 1)。至少当用户明确请求时。

也许允许零大小的对象本来是可以防止碎片化的更干净的解决方案。但是,当您在 SO 上搜索零大小的对象时,您会发现答案不同(有时没有说服力)的问题,并很快注意到这是一个有争议的话题。允许零大小的对象需要改变 C++ 语言的核心,并且考虑到 C++ 语言已经非常复杂的事实,标准委员会可能决定采用最小侵入性的方法,并刚刚引入了一个新属性。

与上面的其他缓解措施一起,它最终解决了由于不允许零大小对象而导致的所有问题。尽管从基本的角度来看它可能不是最好的解决方案,但它是有效的。