Является ли законным повторное использование памяти из основного массива типов для другого (но все же фундаментального) массива типов

Это является продолжением этого другого вопроса о повторном использовании памяти. Поскольку первоначальный вопрос касался конкретной реализации, ответ был связан с этой конкретной реализацией.

Поэтому я задаюсь вопросом, законно ли в согласованной реализации повторно использовать память массива фундаментального типа для массива другого типа:

  • оба типа являются фундаментальным типом и как таковые имеют тривиальный dtor и по умолчанию ctor
  • оба типа имеют одинаковый размер и требования к выравниванию

Я закончил следующим примером кода:

#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, но я не смог найти, является ли строка, помеченная вопросом здесь, или недействительна.

Объекты Float повторно используют память из объектов int, поэтому время жизни ints заканчивается, когда начинается время жизни поплавков, поэтому правило сглаживания не должно быть проблемой. Массив был динамически распределен, поэтому объекты (int и floats) фактически созданы в массиве типа void, возвращаемом operator new. Поэтому я думаю, что все должно быть в порядке.

Но поскольку он позволяет заменять объект низкого уровня, который обычно не одобряется в современных C++, я должен признать, что у меня есть сомнения...

Итак, вопрос: делает ли над кодом код UB, и если да, то где и почему?

Отказ от ответственности: я бы посоветовал против этого кода в переносной кодовой базе, и это действительно вопрос юриста на языке.

Ответ 1

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

Правильный. Но, вероятно, не в том смысле, который вы ожидаете. [expr.static.cast]

PRvalue типа "указатель на cv1 void " может быть преобразован в prvalue типа "указатель на cv2 T ", где T - тип объекта, а cv2 - это то же самое cv-qualification, что и более высокая cv-квалификация, чем cv1. Если исходное значение указателя представляет адрес A байта в памяти, и A не удовлетворяет требованию выравнивания T, то результирующее значение указателя не указывается. В противном случае, если исходное значение указателя указывает на объект a, и есть объект b типа T (игнорирующий cv-qualification), который является взаимно конвертируемым с a, результатом является указатель на b. В противном случае значение указателя не изменяется при преобразовании.

В 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]

Объект создается определением посредством нового выражения, когда неявно изменяется активный член объединения или создается временный объект.

Заметно отсутствует назначение. Нет int. Фактически, путем исключения, in является недопустимым указателем и разыменованием, это UB.

Позднее float* все также следует за UB.

Даже в отсутствие всех вышеупомянутых UB путем правильного использования new (pointer) Type{i}; для создания объектов не существует объекта массива. Объекты (несвязанные) просто находятся рядом друг с другом в памяти. Это означает, что арифметика указателя с результирующим указателем также является UB. [expr.add]

Когда выражение с интегральным типом добавляется или вычитается из указателя, результат имеет тип операнда указателя. Если выражение P указывает на элемент x[i] объекта массива x с n элементами, выражения P + J и J + P (где J имеет значение j) указывают на (возможно-гипотетический) элемент x[i+j] if 0 ≤ i+j ≤ n; в противном случае поведение не определено. Аналогично, выражение P - J указывает на (возможно, гипотетический) элемент x[i−j] if 0 ≤ i−j ≤ n; в противном случае поведение не определено.

Где гипотетический элемент относится к одному из прошедших (гипотетических) элементов. Обратите внимание, что указатель на один прошлый элемент, который находится в том же месте адреса, что и другой объект, не указывает на этот другой объект.

Ответ 2

Прохожий По ответам объясняется, почему пример программы имеет неопределенное поведение. Я попытаюсь ответить, как повторно использовать хранилище без UB с минимальным UB (повторное использование хранилища для массивов технически невозможно в стандартном C++, учитывая текущую формулировку стандарта, поэтому для повторного использования программист должен полагаться на реализацию "делать правильные вещи").

Преобразование указателя не приводит к автоматическому представлению объектов. Сначала вы должны построить объекты с плавающей точкой. Это начинает свою жизнь и заканчивает время жизни объектов int (для нетривиальных объектов, деструктор должен быть вызван первым):

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

Вы можете использовать указатель, возвращенный помещением new (который отбрасывается в моем примере) напрямую, чтобы использовать только что построенные объекты с float, или вы можете std::launder указатель buffer:

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

Тем не менее, гораздо более типично повторное использование хранения типа unsigned char (или std::byte), а не хранения объектов int.

Ответ 3

Я только что появился, потому что мне показалось, что есть хотя бы один неотвеченный вопрос, который не был высказан вслух, извините, если это не так. Я думаю, что ребята блестяще ответили на главный вопрос этой проблемы: где и почему это неопределенное поведение; user2079303 дал несколько идей, как исправить это. Я постараюсь ответить на вопрос, как исправить код и почему он действителен. Прежде чем начинать читать мой пост, пожалуйста, прочитайте ответы и прокомментируйте обсуждения по тем ответам Passer By и user2079303.

В основном проблема заключается в том, что объекты не существуют, даже если им действительно ничего не нужно, кроме хранилища, чтобы существовать. Тем не менее это сказано в разделе жизненного цикла стандарта, однако в разделе объектной модели C++, прежде чем будет указано, что

Объект создается определением (6.1), новым выражением (8.3.4), при неявном изменении активного члена объединения (12.3) или при создании временного объекта (7.4, 15.2).

Немного сложное определение концепции объекта, но имеет смысл. Эта проблема более точно рассматривается в предложении. Неявное создание объектов для манипуляций с объектом низкого уровня для упрощения объектной модели. До тех пор мы должны явно создать объект упомянутыми средствами. Один из тех, которые будут работать, для этого случая - это выражение нового места размещения, новое место размещения - это не-выделяющее новое выражение, создающее объект. В этом конкретном случае это поможет нам создать недостающие объекты массива и плавающие объекты. В приведенном ниже коде показано, что я придумал, включая некоторые комментарии и инструкции по сборке, связанные с строками (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. С -O0 operator new GCC -O0 фактически вызывается operator new, а с -O1 он также опущен. Давайте забудем о формальности стандарта на секунду и подумаем прямо из практического смысла. Зачем нам нужно называть функции, которые ничего не делают, нет ничего, что мешало бы ему работать без них, правильно? Поскольку C++ - это именно тот язык, где весь контроль над памятью предоставляется программе, а не некоторым библиотекам времени исполнения или виртуальной машине и т.д. Одна из причин, по которой я могу думать здесь, состоит в том, что стандарт снова дает компиляторам больше свободы на оптимизация, ограничивающая программу дополнительным действием. Возможно, идея заключалась в том, что компилятор может выполнять любое переупорядочение, опуская магию с машинным кодом, зная только определение, новое выражение, объединение, временные объекты, как поставщики новых объектов, которые определяют алгоритм оптимизации. Скорее всего, в реальности нет таких оптимизаций, которые испортили бы ваш код, если бы вы выделили память и не вызывали на нем новый оператор для тривиальных типов. Интересным фактом является то, что те, кто не выделяет версии new operator, зарезервированы и не разрешены для замены, может быть, это именно то, что должно быть простейшими формами, сообщающими компилятору о новом объекте.