Скалярное 'new T' против массива 'new T [1]'

Недавно мы обнаружили, что какой-то код использует new T[1] систематически (должным образом соответствует delete[]), и мне интересно, насколько это безопасно, или есть некоторые недостатки в сгенерированном коде (в пространстве или времени/производительности). Конечно, это было скрыто за слоями функций и макросов, но это не относится к делу.

Логично, что мне кажется, что оба похожи, но они?

Разрешено ли компиляторам преобразовывать этот код (используя литерал 1, а не переменную, а через слои функций), чтобы 1 превращался в переменную аргумента 2 или 3 раза, прежде чем он достигнет кода с помощью new T[n]), в скаляр new T?

Любые другие соображения/вещи, чтобы знать о разнице между этими двумя?

Ответ 1

Нет, компилятору не разрешено заменять new T[1] на new T. operator new и operator new[] (и соответствующие удаления) являются заменяемыми ([basic.stc.dynamic]/2). Определяемая пользователем замена может определить, какой из них вызывается, поэтому правило "как будто" не позволяет эту замену.

Примечание: если компилятор может обнаружить, что эти функции не были заменены, он может внести это изменение. Но в исходном коде нет ничего, что указывало бы на то, что заменяемые компилятором функции заменяются. Замена обычно выполняется во время ссылки, просто путем ссылки на версии замены (которые скрывают предоставленную библиотекой версию); как правило, слишком поздно, чтобы компилятор знал об этом.

Ответ 2

Если T не имеет тривиального деструктора, то для обычных реализаций компилятора new T[1] имеет издержки по сравнению с new T. Версия массива будет выделять немного большую область памяти для хранения количества элементов, поэтому в delete[] он знает, сколько деструкторов должно быть вызвано.

Итак, у него есть накладные расходы:

  • немного больше памяти должно быть выделено
  • delete[] будет немного медленнее, так как ему нужен цикл для вызова деструкторов, вместо вызова простого деструктора (здесь разница заключается в издержках цикла)

Проверьте эту программу:

#include <cstddef>
#include <iostream>

enum Tag { tag };

char buffer[128];

void *operator new(size_t size, Tag) {
    std::cout<<"single: "<<size<<"\n";
    return buffer;
}
void *operator new[](size_t size, Tag) {
    std::cout<<"array: "<<size<<"\n";
    return buffer;
}

struct A {
    int value;
};

struct B {
    int value;

    ~B() {}
};

int main() {
    new(tag) A;
    new(tag) A[1];
    new(tag) B;
    new(tag) B[1];
}

На моей машине он печатает:

single: 4
array: 4
single: 4
array: 12

Поскольку B имеет нетривиальный деструктор, компилятор выделяет дополнительные 8 байтов для хранения количества элементов (поскольку это 64-битная компиляция, для этого требуется 8 дополнительных байтов) для версии массива. Поскольку A выполняет тривиальный деструктор, версия массива A не нуждается в этом дополнительном пространстве.


Примечание: как отмечает Deduplicator, есть небольшое преимущество в производительности при использовании версии массива, если деструктор является виртуальным: на delete[] компилятору не нужно вызывать деструктор виртуально, потому что он знает, что тип является T, Вот простой пример, чтобы продемонстрировать это:

struct Foo {
    virtual ~Foo() { }
};

void fn_single(Foo *f) {
    delete f;
}

void fn_array(Foo *f) {
    delete[] f;
}

Clang оптимизирует этот случай, но GCC не делает этого: Godbolt.

Для fn_single clang испускает проверку nullptr, а затем виртуально вызывает функцию destructor+operator delete. Это должно быть сделано так, как f может указывать на производный тип, у которого есть непустой деструктор.

Для fn_array clang испускает проверку nullptr, а затем вызывает прямо к operator delete, не вызывая деструктор, поскольку он пуст. Здесь компилятор знает, что f фактически указывает на массив объектов Foo, он не может быть производным типом, поэтому он может опускать вызовы пустых деструкторов.

Ответ 3

Правило простое: delete[] должно соответствовать new[], а delete должно соответствовать new: поведение при использовании любой другой комбинации не определено.

Компилятор действительно позволяет превратить new T[1] в простой new T (и правильно обращаться с delete[]), благодаря правилу "как будто". Хотя я не сталкивался с компилятором, который делает это.

Если у вас есть какие-либо сомнения по поводу производительности, то профилируйте ее.