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

Initializing an array of trivially_copyable but not default_constructible objects from bytes. Confusion in [intro.object]

我们正在从二级存储中初始化 trivially_copiable 个对象的(大)数组, or 等问题让我们对我们实施的方法缺乏信心。

下面是一个最小的例子,试图说明代码中“令人担忧”的部分。 也请 find it on Godbolt.

例子

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

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

    double alpha;
    double beta;
};

信任cppreference:

Objects of trivially-copyable types that are not potentially-overlapping subobjects are the only C++ objects that may be safely copied with std::memcpy or serialized to/from binary files with std::ofstream::write()/std::ifstream::read().

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

std::unique_ptr<Foo[]> invalid{new Foo[dynamicSize]}; // Error, no default ctor

备选方案 (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";

是否存在 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";

此替代方案的灵感来自 this post。然而,它有更好的定义吗?似乎仍然没有明确创建 Foo.

类型的对象

在访问 Foo 数据成员时,值得注意的是删除了 reinterpret_cast(此转换可能违反了 Type Aliasing rule)。

总体问题

您最终要做的是通过从其他地方 memcpying 字节创建某种类型 T 的数组,而不是首先在数组中默认构造 Ts。

Pre-C++20 在某些时候不引发 UB 就无法做到这一点。

问题最终归结为[intro.object]/1, which defines the ways objects get created:

An object is created by a definition, by a new-expression, when implicitly changing the active member of a union, or when a temporary object is created ([conv.rval], [class.temporary]).

如果你有一个 T* 类型的指针,但是在那个地址没有创建 T 对象,你不能假装指针指向一个实际的 T ].您必须使 T 出现,这需要执行上述操作之一。唯一可用的是 new 表达式,它要求 T 是默认可构造的。

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

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


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

A class S is an implicit-lifetime class if it is an aggregate or has at least one trivial eligible constructor and a trivial, non-deleted destructor.

你的 class 符合条件,因为它有一个平凡的复制构造函数(即“eligible”)和一个平凡的析构函数。

如果创建字节类型数组(unsigned charstd::bytechar),this is said to "implicitly create objects" in that storage。此 属性 也适用于 mallocoperator new 返回的内存。这意味着如果您对指向该存储的指针执行某些类型的未定义行为,系统将自动创建对象(在创建数组的位置),使该行为得到明确定义。

因此,如果您分配此类存储,将指向它的指针转换为 T*,然后像指向 T 一样开始使用它,系统将自动创建 [=11] =]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";

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

我的第一个问题是:你想达到什么目的?

  • 单独阅读每个条目是否有问题?
  • 您是否假设您的代码会通过读取数组来加速?
  • 延迟真的是一个因素吗?
  • 为什么不能只向 class 添加默认构造函数?
  • 为什么不能增强 input.read() 直接读入数组?参见 std::extent_v<T>

假设您定义了约束条件,我将从以简单的方式编写它开始,一次读取一个条目,然后对其进行基准测试。 话虽如此,您所描述的是一个通用范例,是的,可以打破很多规则。

C++ 对诸如对齐之类的事情非常(过度)谨慎,这在某些平台上可能是问题,而在其他平台上则不是问题。这只是“未定义的行为”,因为 C++ 标准本身不能提供跨平台保证,即使许多技术在实践中工作得很好。

执行此操作的教科书方法是创建一个空缓冲区并 memcpy 到一个适当的对象中,但是由于您的输入被序列化(可能由另一个系统),实际上并不能保证填充和对齐将匹配本地编译器为序列确定的内存布局,因此您仍然必须一次执行一项。

我的建议是编写单元测试以确保没有问题,并可能将其作为静态断言嵌入到代码中。您描述的技术打破了一些 C++ 规则,但这并不意味着它正在打破,例如 x86 规则。

备选方案 (A):在对象的生命周期开始之前访问对象的“非静态”成员。
程序的行为未定义(参见:[basic.life])。

备选方案 (B):隐式调用隐式删除的默认构造函数。
该程序格式错误(参见:[class.default.ctor])。

我不确定后者。如果有更懂行的人知道if/why这是UB请指正。

您可以自己管理内存,然后 return 使用自定义删除器的 unique_ptr。由于不能使用new[],所以不能使用普通版的unique_ptr<T[]>,需要使用分配器手动调用析构函数和删除器。

template <class Allocator = std::allocator<Foo>>
struct FooDeleter : private Allocator {
  using pointer = typename std::allocator_traits<Allocator>::pointer;

  explicit FooDeleter(const Allocator &alloc, len) : Allocator(alloc), len(len) {}

  void operator()(pointer p) {
    for (pointer i = p; i != p + len; ++i) {
      Allocator::destruct(i);
    }
    Allocator::deallocate(p, len);
  }

  size_t len;
};

std::unique_ptr<Foo[], FooDeleter<>> create(size_t len) {
   std::allocator<Foo> alloc;
   Foo *p = nullptr, *i = nullptr;
   try {
     p = alloc.allocate(len);
     for (i = p; i != p + len; ++i) {
       alloc.construct(i , 1.0f, 2.0f);
     }
   } catch (...) {
     while (i > p) {
       alloc.destruct(i--);
     }
     if (p)
       alloc.deallocate(p);
     throw;
   }
   return std::unique_ptr<Foo[], FooDeleter<>>{p, FooDeleter<>(alloc, len)};
}