从字节初始化 trivially_copyable 但不是 default_constructible 对象的数组。[intro.object] 中的混乱

Ad *_*d N 2 c++ strict-aliasing undefined-behavior language-lawyer

我们正在trivially_copiable从辅助存储中初始化(大型)对象数组,诸如此类的问题使我们对所实现的方法缺乏信心。

下面是一个最小的示例,试图说明代码中“令人担忧”的部分。也请在 Godbolt 上找到它

例子

让我们有一个trivially_copyable但不是default_constructible用户类型:

struct Foo
{
    Foo(double a, double b) :
        alpha{a}, 
        beta{b}
    {}

    double alpha;
    double beta;
};
Run Code Online (Sandbox Code Playgroud)

信任cppreference

不存在潜在重叠子对象的普通可复制类型的对象是唯一可以使用 std::memcpy 安全复制或使用 std::ofstream::write()/std::ifstream 与二进制文件序列化的 C++ 对象::读()。

现在,我们想要将二进制文件读入动态数组中Foo。由于Foo不可默认构造,我们不能简单地:

std::unique_ptr<Foo[]> invalid{new Foo[dynamicSize]}; // Error, no default ctor
Run Code Online (Sandbox Code Playgroud)

替代方案(A)

使用未初始化的unsigned char数组作为存储。

std::unique_ptr<unsigned char[]> storage{
    new unsigned char[dynamicSize * sizeof(Foo)] };

input.read(reinterpret_cast<char *>(storage.get()), dynamicSize * sizeof(Foo));

std::cout << reinterpret_cast<Foo *>(storage.get())[index].alpha << "\n";
Run Code Online (Sandbox Code Playgroud)

是否存在 UB,因为实际类型的对象Foo从未在 中显式创建storage

替代方案(B)

存储显式地键入为Foo.

std::unique_ptr<Foo[]> storage{
    static_cast<Foo *>(::operator new[](dynamicSize * sizeof(Foo))) };

input.read(reinterpret_cast<char *>(storage.get()), dynamicSize * sizeof(Foo));

std::cout << storage[index].alpha << "\n";
Run Code Online (Sandbox Code Playgroud)

这个替代方案是受到这篇文章的启发。然而,它有更好的定义吗?似乎仍然没有显式创建 type 的对象Foo

值得注意的是,它消除了reinterpret_cast访问Foo数据成员时的(此强制转换可能违反了类型别名规则)。

总体问题

  • 标准是否定义了这些替代方案?它们实际上不同吗?

    • 如果没有,是否有正确的方法来实现这一点(无需首先将所有 Foo 实例初始化为随后立即丢弃的值)
  • C++ 标准版本之间的未定义行为有什么区别吗?(特别是,请参阅有关 C++20 的评论)

Nic*_*las 8

您最终要做的是通过从其他地方获取字节T来创建某种类型的数组memcpy,而不是先默认构造T数组中的 s 。

C++20 之前的版本无法在不引发 UB 的情况下做到这一点。

问题最终归结为[intro.object]/1,它定义了创建对象的方式

当隐式更改联合的活动成员或创建临时对象([conv.rval],[class.temporary])时,通过定义、new 表达式创建对象。

如果您有一个类型为 的指针T*,但T在该地址中尚未创建任何对象,则不能假装该指针指向实际的T。您必须使其T成为现实,这需要执行上述操作之一。唯一可以满足您的目的的是 new 表达式,它要求 是T默认可构造的。

如果你想memcpy进入这样的对象,它们必须首先存在。所以你必须创建它们。对于此类对象的数组,这意味着它们需要默认可构造。

因此,如果可能的话,您需要一个(可能是默认的)默认构造函数。


在 C++20 中,某些操作可以隐式创建对象(引发“隐式对象创建”或 IOC)。IOC 仅适用于隐式生命周期类型,对于类

如果类 S 是一个聚合或者至少具有一个普通的合格构造函数和一个普通的、未删除的析构函数,则它是隐式生命周期类。

您的类符合资格,因为它有一个简单的复制构造函数(“合格”)和一个简单的析构函数。

如果您创建一个字节类型数组(unsigned charstd::bytechar),则称为在该存储中“隐式创建对象”malloc此属性也适用于和返回的内存operator new。这意味着,如果您对指向该存储的指针执行某些类型的未定义行为,系统将自动创建对象(在创建数组的位置),从而使该行为得到明确定义。

因此,如果您分配这样的存储,将指向它的指针转换为 a T*,然后开始使用它,就像它指向 a 一样T,系统将自动T在该存储中创建 s ,只要它适当对齐即可。

因此,你的替代方案 A 工作得很好:

当您应用[index]强制转换指针时,C++ 将在该存储中追溯创建一个数组Foo。也就是说,因为您像使用Foo存在数组一样使用内存,所以 C++20 将在那里创建存在数组Foo,就像您在语句中创建它一样new unsigned char

然而,替代方案 B 不会按原样工作。您没有使用它new[] Foo来创建该数组,因此您无法使用delete[] Foo它来删除它。您仍然可以unique_ptr使用,但您必须创建一个显式调用operator delete指针的删除器:

struct mem_delete
{
  template<typename T>
  void operator(T *ptr)
  {
    ::operator delete[](ptr);
  }
};

std::unique_ptr<Foo[], mem_delete> storage{
    static_cast<Foo *>(::operator new[](dynamicSize * sizeof(Foo))) };

input.read(reinterpret_cast<char *>(storage.get()), dynamicSize * sizeof(Foo));

std::cout << storage[index].alpha << "\n";
Run Code Online (Sandbox Code Playgroud)

再次storage[index]创建一个数组,T就好像它是在分配内存时创建的一样。

  • @VainMan:正是指针的使用导致了UB,因此导致了IOC追溯发生。因为IOC只发生在一个地方(一开始分配内存的地方),所以它只发生一次。因此,如果没有*单个对象*创建可以满足这两种用途,那么您就得到了 UB。请注意,如果“T”是标准布局,并且其第一个成员是“U”,那么它们都具有相同的地址,因此 IOC 可以创建一个“T”,这足以满足这两个用例。 (3认同)