将基本类型数组中的内存重用于不同的(但仍然是基本的)类型数组是否合法

Is it legal to reuse memory from a fundamental type array for a different (yet still fundamental) type array

这是关于内存重用的另一个 的跟进。由于最初的问题是关于具体实现的,因此答案与该具体实现相关。

所以我想知道,在一致的实现中,为提供的不同类型的数组重新使用基本类型数组的内存是否合法:

我以以下示例代码结束:

#include <iostream>

constexpr int Size = 10;

void *allocate_buffer() {
    void * buffer = operator new(Size * sizeof(int), std::align_val_t{alignof(int)});
    int *in = reinterpret_cast<int *>(buffer); // Defined behaviour because alignment is ok
    for (int i=0; i<Size; i++) in[i] = i;  // Defined behaviour because int is a fundamental type:
                                           // lifetime starts when is receives a value
    return buffer;
}
int main() {
    void *buffer = allocate_buffer();        // Ok, defined behaviour
    int *in = static_cast<int *>(buffer);    // Defined behaviour since the underlying type is int *
    for(int i=0; i<Size; i++) {
        std::cout << in[i] << " ";
    }
    std::cout << std::endl;
    static_assert(sizeof(int) == sizeof(float), "Non matching type sizes");
    static_assert(alignof(int) == alignof(float), "Non matching alignments");
    float *out = static_cast<float *>(buffer); //  (question here) Declares a dynamic float array starting at buffer
    // std::cout << out[0];      // UB! object at &out[0] is an int and not a float
    for(int i=0; i<Size; i++) {
        out[i] = static_cast<float>(in[i]) / 2;  // Defined behaviour, after execution buffer will contain floats
                                                 // because float is a fundamental type and memory is re-used.
    }
    // std::cout << in[0];       // UB! lifetime has ended because memory has been reused
    for(int i=0; i<Size; i++) {
        std::cout << out[i] << " ";         // Defined behaviour since the actual object type is float *
    }
    std::cout << std::endl;
    return 0;
}

我添加了注释来解释为什么我认为这段代码应该定义行为。恕我直言,一切都很好并且符合 AFAIK 标准,但我无法找到标记为 question here 的行是否有效。

Float 对象确实会重复使用 int 对象的内存,因此当 float 的生命周期开始时,int 的生命周期结束,因此严格的别名规则应该不是问题。数组是动态分配的,因此对象(int 和 float)实际上都是在 operator new 返回的 void 类型 数组中创建的。所以我认为一切都应该没问题。

但由于它允许低级对象替换,这在现代 C++ 中通常是不受欢迎的,我必须承认我有疑问...

所以问题是:上面的代码是否调用了 UB,如果是,在哪里调用,为什么?

免责声明:我建议不要在可移植代码库中使用此代码,这确实是一个语言律师问题。

int *in = reinterpret_cast<int *>(buffer); // Defined behaviour because alignment is ok

正确。但可能不是您期望的那种感觉。 [expr.static.cast]

A prvalue of type “pointer to cv1 void” can be converted to a prvalue of type “pointer to cv2 T”, where T is an object type and cv2 is the same cv-qualification as, or greater cv-qualification than, cv1. If the original pointer value represents the address A of a byte in memory and A does not satisfy the alignment requirement of T, then the resulting pointer value is unspecified. Otherwise, if the original pointer value points to an object a, and there is an object b of type T (ignoring cv-qualification) that is pointer-interconvertible with a, the result is a pointer to b. Otherwise, the pointer value is unchanged by the conversion.

buffer处没有int也没有任何指针可相互转换的对象,因此指针值没有改变。 in 是指向原始内存区域的 int* 类型的指针。

for (int i=0; i<Size; i++) in[i] = i;  // Defined behaviour because int is a fundamental type:
                                       // lifetime starts when is receives a value

不正确。 [intro.object]

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.

明显没有赋值。没有创建 int。事实上,通过排除,in 是一个 invalid pointer,并且取消引用它是 UB。

后面的float*也都是UB

即使通过正确使用 new (pointer) Type{i}; 创建对象而没有上述所有 UB,也不存在 array 对象。 (不相关的)对象恰好在内存中并排。这意味着指针算术结果指针也是 UB。 [expr.add]

When an expression that has integral type is added to or subtracted from a pointer, the result has the type of the pointer operand. If the expression P points to element x[i] of an array object x with n elements, the expressions P + J and J + P (where J has the value j) point to the (possibly-hypothetical) element x[i+j] if 0 ≤ i+j ≤ n; otherwise, the behavior is undefined. Likewise, the expression P - J points to the (possibly-hypothetical) element x[i−j] if 0 ≤ i−j ≤ n; otherwise, the behavior is undefined.

其中假设元素是指最后一个(假设的)元素。请注意,指向恰好与另一个对象位于同一地址位置的尾后元素的指针并不指向另一个对象。

路人的回答涵盖了为什么示例程序有未定义的行为。我将尝试回答如何在没有 UB 的情况下以最少的 UB 重用存储 (鉴于标准的当前措辞,在标准 C++ 中,技术上不可能重用数组的存储,因此要实现重用,程序员必须依赖 "do the right thing") 的实现。

转换指针不会自动将对象显示为存在。您必须首先构造浮动对象。 This 开始它们的生命周期并结束 int 对象的生命周期(对于非平凡的对象,需要先调用析构函数):

for(int i=0; i<Size; i++)
    new(in + i) float;

你可以直接使用 placement new 返回的指针(在我的例子中被丢弃)来使用新构造的 float 对象,或者你可以 std::launder buffer 指针:

float *out = std::launder(reinterpret_cast<float*>(buffer));

但是,重用 unsigned char(或 std::byte)类型的存储比重用 int 类型的存储更典型 对象。

我刚刚突然出现,因为我觉得至少有一个未回答的问题,没有大声说出来,如果不是真的,我们深表歉意。我认为这些人出色地回答了这个问题的主要问题:未定义行为的位置和原因; user2079303 给出了一些解决方法。我将尝试回答如何修复代码及其有效原因的问题。在开始阅读我的 post 之前,请阅读路人和用户 2079303 的答案和评论讨论。

基本上问题是对象不存在,即使它们实际上不需要任何东西,除了存储,存在。这在标准的生命周期部分中有说明,但是,在 C++ 对象模型部分中说明

An object is created by a definition (6.1), by a new-expression (8.3.4), when implicitly changing the active member of a union (12.3), or when a temporary object is created (7.4, 15.2).

对象概念的定义有点棘手,但很有道理。 proposal Implicit creation of objects for low-level object manipulation 中更精确地解决了该问题以简化对象模型。在那之前,我们应该通过提到的方式明确地创建一个对象。其中一个可行的方法是 new-placement 表达式,new-placement 是一个非分配的 new-expression,它创建一个对象。对于这种特殊情况,这将帮助我们创建丢失的数组对象和浮动对象。下面的代码显示了我的想法,包括一些注释和与这些行相关的汇编指令(使用了 clang++ -g -O0)。

constexpr int Size = 10;

void* allocate_buffer() {

  // No alignment required for the `new` operator if your object does not require
  // alignment greater than alignof(std::max_align_t), what is the case here
  void* buffer = operator new(Size * sizeof(int));
  // 400fdf:    e8 8c fd ff ff          callq  400d70 <operator new(unsigned long)@plt>
  // 400fe4:    48 89 45 f8             mov    %rax,-0x8(%rbp)


  // (was missing) Create array of integers, default-initialized, no
  // initialization for array of integers
  new (buffer) int[Size];
  int* in = reinterpret_cast<int*>(buffer);
  // Two line result in a basic pointer value copy
  // 400fe8:    48 8b 45 f8             mov    -0x8(%rbp),%rax
  // 400fec:    48 89 45 f0             mov    %rax,-0x10(%rbp)


  for (int i = 0; i < Size; i++)
    in[i] = i;
  return buffer;
}

int main() {

  void* buffer = allocate_buffer();
  // 401047:    48 89 45 d0             mov    %rax,-0x30(%rbp)


  // static_cast equivalent in this case to reinterpret_cast
  int* in = static_cast<int*>(buffer);
  // Static cast results in a pointer value copy
  // 40104b:    48 8b 45 d0             mov    -0x30(%rbp),%rax
  // 40104f:    48 89 45 c8             mov    %rax,-0x38(%rbp)


  for (int i = 0; i < Size; i++) {
    std::cout << in[i] << " ";
  }
  std::cout << std::endl;
  static_assert(sizeof(int) == sizeof(float), "Non matching type sizes");
  static_assert(alignof(int) == alignof(float), "Non matching alignments");
  for (int i = 0; i < Size; i++) {
    int t = in[i];


    // (was missing) Create float with a direct initialization
    // Technically that is reuse of the storage of the array, hence that array does
    // not exist anymore.
    new (in + i) float{t / 2.f};
    // No new is called
    // 4010e4:  48 8b 45 c8             mov    -0x38(%rbp),%rax
    // 4010e8:  48 63 4d c0             movslq -0x40(%rbp),%rcx
    // 4010ec:  f3 0f 2a 4d bc          cvtsi2ssl -0x44(%rbp),%xmm1
    // 4010f1:  f3 0f 5e c8             divss  %xmm0,%xmm1
    // 4010f5:  f3 0f 11 0c 88          movss  %xmm1,(%rax,%rcx,4)


    // (was missing) Create int array on the same storage, default-initialized, no
    // initialization for an array of integers
    new (buffer) int[Size];
    // No code for new is generated
  }


    // (was missing) Create float array, default-initialized, no initialization for an array
    // of floats
  new (buffer) float[Size];
  float* out = reinterpret_cast<float*>(buffer);
  // Two line result in a simple pointer value copy
  // 401108:    48 8b 45 d0             mov    -0x30(%rbp),%rax
  // 40110c:    48 89 45 b0             mov    %rax,-0x50(%rbp)


  for (int i = 0; i < Size; i++) {
    std::cout << out[i] << " ";
  }
  std::cout << std::endl;
  operator delete(buffer);
  return 0;
}

即使使用-O0,机器代码中基本上也省略了所有新放置表达式。使用 GCC -O0 operator new 实际上被调用,使用 -O1 它也被省略。让我们暂时忘掉标准的形式,直接从实际角度思考。为什么我们需要实际调用什么都不做的函数,没有什么能阻止它在没有这些的情况下工作,对吧?因为 C++ 正是将对内存的全部控制权交给程序的语言,而不是某些运行时库或虚拟机等。我可能认为这里的原因之一是该标准再次为编译器提供了更多的优化限制自由该程序进行一些额外的操作。这个想法可能是编译器可以做任何重新排序,省略魔术,机器代码只知道定义、新表达式、联合、临时对象作为指导优化算法的新对象提供者。很可能在现实中没有这样的优化会搞砸你的代码,如果你分配内存并且没有为琐碎的类型调用新的运算符。有趣的是 new operator 的那些非分配版本是保留的,不允许替换,可能这正是告诉编译器一个新对象的最简单形式。