【问题标题】:what is the new feature in c++20 [[no_unique_address]]?c++20 [[no_unique_address]] 的新特性是什么?
【发布时间】:2020-10-28 06:30:33
【问题描述】:

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

【问题讨论】:

标签: c++ attributes c++20


【解决方案1】:

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

  1. 一个空的对象。

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

第一个非常简单,您使用的引用甚至说明了一个重要的应用程序。 std::allocator 类型的对象实际上不存储任何东西。它只是全局::new::delete 内存分配器的基于类的接口。不存储任何类型数据(通常使用全局资源)的分配器通常称为“无状态分配器”。

分配器感知容器需要存储用户提供的分配器的值(默认为该类型的默认构造分配器)。这意味着容器必须具有该类型的子对象,该子对象由用户提供的分配器值初始化。而且那个子对象占用空间……理论上。

考虑std::vector。这种类型的常见实现是使用 3 个指针:一个指向数组的开头,一个指向数组有用部分的结尾,一个指向数组分配块的结尾。在 64 位编译中,这 3 个指针需要 24 字节的存储空间。

无状态分配器实际上没有任何数据要存储。但在 C++ 中,每个对象的大小至少为 1。因此,如果 vector 存储了一个分配器作为成员,则每个 vector&lt;T, Alloc&gt; 必须占用至少 32 个字节,即使分配器不存储任何内容。

对此的常见解决方法是从Alloc 派生vector&lt;T, Alloc&gt; 自身。原因是基类子对象的大小不需要为 1。如果基类没有成员且没有非空基类,则允许编译器优化派生类中的基类实际上不占用空间。这称为“空基优化”(标准布局类型需要它)。

因此,如果您提供无状态分配器,则从该分配器类型继承的 vector&lt;T, Alloc&gt; 实现仍然只有 24 字节大小。

但是有一个问题:你必须从分配器中继承。这真的很烦人。而且很危险。首先,分配器可以是final,这实际上是标准允许的。其次,分配器的成员可能会干扰vector 的成员。第三,它是人们必须学习的习语,这使它成为 C++ 程序员的民间智慧,而不是任何人都可以使用的明显工具。

因此,虽然继承是一种解决方案,但它并不是一个很好的解决方案。

这就是[[no_unique_address]] 的用途。它将允许容器将分配器存储为成员子对象而不是基类。如果分配器为空,则[[no_unique_address]] 将允许编译器使其不占用类定义中的空间。所以这样的vector 大小仍然可能是 24 字节。


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

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

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

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

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

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

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

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

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

【讨论】:

    【解决方案2】:

    为了了解[[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...
    

    【讨论】:

    • +1,但这在实践中并不是真正的问题,因为大多数(如果不是全部)unique_ptr 实现通过将指针和删除器存储在 compressed_pair 或使用空的类似类型中来避免问题基础优化。当然,正如 Nicol 的回答所说,这并不是万无一失的,因为您可能有 final 删除器类型等。
    • @Praetorian:“这在实践中并不是真正的问题” 为什么不呢?因为普通C++程序员不允许自己写compressed_pairboost::compressed_pair存在这一事实足以证明这是人们真正需要的东西。就像boost::noncopyable 证明我们需要一种方法来制作不可复制的类型。
    • @Nicol 我想你误解了我想说的话。我的意思是unique_ptr stdlib 实现不超过指针的大小,假设删除器是无状态的。答案似乎暗示unique_ptr 的大小将始终大于[[no_unique_address]] 存在之前的大小。
    • @Praetorian 如果可以通过增加复杂性来解决问题,那么它仍然是一个问题。
    【解决方案3】:

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

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

    这本质上是 C++ 标准中非常基本的定义的结果:唯一身份规则(正如 Nicol Bolas 所解释的),但也来自将“对象”定义为非空字节序列。

    但是,这会在编写通用代码时导致令人不快的问题。这在某种程度上是意料之中的,因为这里的极端情况(-> 空类型)会受到特殊处理,这会偏离其他情况的系统行为(-> 大小以非系统方式增加)。

    效果是:

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

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

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

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

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

    【讨论】:

    • 在我看来,一个大小为 N 的对象应该被定义为总共有 N+1 个不同的地址,其中第一个 N 将是指向字节的指针,最后一个 N 将是指针"刚刚过去”字节。指向对象中的字节的指针不会与指向任何其他字节的任何指针共享地址,并且不会仅通过对象中的字节的指针与仅通过任何其他字节的指针共享地址,但是指针指向一个对象中的一个字节可以与一个指针共享一个地址,该指针刚刚超过一个对象中的一个字节。
    • 我也不明白的一件事是,为什么要让每个程序员选择无状态的对象(它们没有非静态数据成员)首先拥有一个唯一的地址。地址是对象的某种(不可变)状态,对我来说这没有多大意义。
    • 例如,如果需要一种数据结构,将 N 个事物存储为 N/256 个对象,每个对象包含 256 个对象,以及一个包含 N%256 个对象的对象,则能够处理 256 的倍数当 N 是 256 的倍数时,与任何其他数字一样,都比排除或填充“尾部”对象更方便。
    • @Peter-ReinstateMonica:即使不允许此类对象的数组或具有此类目标类型的指针的算术运算,也可以满足零大小对象的大部分用途(给定 int foo[0],*p=foo,*q=p+0;(q-p)/sizeof (*p) 的计算不会造成任何问题)。
    • 我会添加 std::is_empty 作为另一种解决方法,以减轻这种设计选择。给股票sizeof(SomeEmptyStruct) 不会像最初预期的那样返回 0,我已经用它编写了一些辅助函数,用于在 API 上执行测试用例来验证空用例。
    猜你喜欢
    • 2020-01-10
    • 2020-10-01
    • 2017-09-16
    • 2020-03-19
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多