Заметка рассчитана на начинающих C++ программистов, которым стало интересно, почему везде твердят, что нужно использовать delete[] для массивов, но вместо внятного объяснения – просто прикрываются магическим "undefined behavior". Немного кода, несколько картинок и взгляд под капот компиляторов – всех заинтересованных прошу под кат.
Введение
Может быть, вы не замечали, или даже просто не обращали внимания, но, когда вы пишете код для освобождения памяти, занятой массивами, то вам не приходится писать количество элементов, которые нужно удалить. При этом всё замечательно работает.
int *p = new SomeClass[42]; // Указываем количество
delete[] p; // Не указываем количество
Это что, магия? Отчасти – да. Причём разработчики различных компиляторов видят и реализуют её по-разному.
Существует два основных подхода к тому, как компиляторы запоминают количество элементов в массиве:
- Запись количества элементов перед самим массивом ("Over-Allocation")
- Хранение количества элементов в обособленном ассоциативном контейнере ("Associative Array")
Over-Allocation
Первый способ, как понятно из названия, реализуется простой записью количества элементов перед массивом. Обратите внимание, что в таком случае указатель, который вы получите после выполнения оператора new, будет указывать на первый элемент массива, а не на его фактическое начало.
Такой указатель ни в коем случае нельзя передавать обычному оператору delete. Скорее всего, он просто удалит первый элемент массива, а остальные оставит нетронутыми. Заметьте, я не просто так написал "скорее всего" – ведь никто не может гарантировать, что произойдёт на самом деле и как дальше будет вести себя ваша программа. Всё зависит от того, какие объекты находились в массиве и делали ли они что-то важное в своих деструкторах. То есть получаем классическое неопределённое поведение. Согласитесь, это не то, чего вы ожидаете при попытке удалить массив.
Интересный факт: в большинстве реализаций стандартной библиотеки, оператор delete внутри себя просто вызывает функцию free. В случае передачи в неё указателя на массив мы получаем ещё одно неопределённое поведение. Это происходит из-за того, что на входе эта функция ожидает указатель, полученный в результате работы функций calloc, malloc или realloc. А как мы выяснили выше, этого не происходит из-за скрытия переменной в начале массива и сдвига указателя на начало массива.
Чем же отличается оператор delete[]? А он как раз считывает количество элементов в массиве, вызывает деструктор для каждого объекта и уже после этого очищает память (вместе со скрытой переменной).
Если кому будет интересно, то примерно в такой псевдокод превращается конструкция delete[] p; при использовании этой стратегии:
// Получаем количество элементов в массиве
size_t n = * (size_t*) ((char*)p - sizeof(size_t));
// Для каждого из них вызываем деструктор
while (n-- != 0)
{
p[n].~SomeClass();
}
// И наконец подчищаем память
operator delete[] ((char*)p - sizeof(size_t));
Этим способом пользуются компиляторы MSVC, GCC и Clang. В этом можно убедиться, взглянув на код работы с памятью в соответствующих репозиториях (GCC и Clang) или воспользовавшись сервисом Compiler Explorer.
Как видно на изображении выше (верхняя часть – код, нижняя – ассемблерный вывод компилятора), я набросал простенький код, в котором объявлена структура и функция для создания массива этих самых структур.
Примечание: пустой деструктор у структуры – это отнюдь не лишний код. Дело в том, что согласно Itanium CXX ABI, для массивов, состоящих из типов с тривиальным деструктором, компилятор должен использовать другой подход к управлению памятью. На самом деле, требований немного больше, и всех их можно посмотреть в разделе 2.7 "Array Operator new Cookies" Itanium CXX ABI. Там же перечислены требования к тому, где и как должна располагаться информация о количестве элементов в массиве.
Что же происходит с точки зрения ассемблера простым языком:
- cтрока N3: запись требуемого количества памяти (20 байт на 5 объектов + 8 байт на размер массива) в регистр;
- cтрока N4: вызов оператора new для выделения памяти;
- cтрока N5: запись количества элементов в начало выделенной памяти;
- cтрока N6: смещение указателя на начало массива на sizeof(size_t), полученный результат является возвращаемым значением.
К достоинствам этого способа можно отнести его лёгкость в реализации и скорость работы, ну а к недостаткам – то, что он не прощает ошибок с некорректным выбором оператора delete. В лучшем случае – сразу получите падение программы с ошибкой "Heap Corrupt", а в худшем – будете долго и мучительно искать причины странного поведения программы.
Associative Array
Второй способ подразумевает существование скрытого глобального контейнера, в котором хранятся указатели на массивы и сколько элементов они содержат. В таком случае перед массивами нет никаких скрытых данных, а вызов delete[] p; реализуется примерно вот так:
// Получаем размер массива из скрытого глобального хранилища
size_t n = arrayLengthAssociation.lookup(p);
// Вызываем деструкторы для каждого элемента
while (n-- != 0)
{
p[n].~SomeClass();
}
// Очищаем память
operator delete[] (p);
Что ж, выглядит не так "магически", как прошлый вариант. Есть ли ещё какие различия? Да.
Кроме уже упомянутого отсутствия скрытых данных перед массивом, мы получаем небольшое замедление работы из-за необходимости поиска данных в глобальном хранилище. Но компенсируем это тем, что программа может более снисходительно относиться к неверному выбору оператора delete.
Данный подход использовался в компиляторе Cfront. Останавливаться на его реализации мы не будем, но если кому интересно покопаться во внутренностях одного из первых C++ компиляторов, то сделать это можно на GitHub.
Мини-послесловие
Всё вышеописанное является внутренней кухней компиляторов, и полагаться на то или иное поведение не стоит. Особенно это касается случаев, когда планируется портирование программы на разные платформы. Благо что есть несколько вариантов как можно избежать данного класса ошибок:
- Использовать семейства функций std::make_*. Например: std::make_unique, std::make_shared,...
- Использовать средства статического анализа для раннего выявления ошибок, например PVS-Studio. ????
Если же вас заинтересовала тема неопределённого поведения и особенностей работы компиляторов, то могу посоветовать ещё парочку дополнительных материалов:
- PVS-Studio. Лекция 11. Неопределённое поведение, или как выстрелить себе в ногу
- Что каждый программист на C должен знать об Undefined Behavior. Часть 1/3
- Что каждый программист на C должен знать об Undefined Behavior. Часть 2/3
- Что каждый программист на C должен знать об Undefined Behavior. Часть 3/3
Комментарии (33)
Amomum
27.07.2022 18:14+1Интересно, а почему вообще были введены эти два оператора? Ведь один delete вполне мог бы проверять, что он сейчас будет удалять, массив или не массив.
Желание снизить оверхед для хранения не-массивов?
screwer
27.07.2022 18:38+3А как он будет это проверять ? А почему именно так, может я по-другому хочу ?
Вот чтобы была возможность сделать свою реализацию - разрешили перегрузить оба варианта.
Amomum
27.07.2022 20:18+1Как - это была бы забота компилятора, так же как сейчас
KyHTEP
28.07.2022 04:38Слишком категорично, не находите?
Если задача не решаема или в заданных условиях решаема не оптимально, то это проблема постановщика задачи, а не компилятора. (Иди туда - не знаю куда, делай то - не знаю что)
На каждый delete чекать принадлежность к массиву или нет - это не оверхед, это брейндемедж ) Разделение операторов - это выбор меньшего из зол.
Тем более, что частота использования "сырых" массивов в прикладных задачах, вместо, хотя бы, std::vector, минимальна.
В геймдеве, например, вообще была бы связка malloc+placement_new(optional), а то просто загрузка всех данных из файла с коррекцией указателей.Amomum
28.07.2022 16:34+2На каждый delete чекать принадлежность к массиву или нет - это не оверхед, это брейндемедж ) Разделение операторов - это выбор меньшего из зол.
Имхо это скорее обычная практика С/С++ - перекладывать оверхед на программиста; я просто хотел убедиться, что причина именно в этом.
Kelbon
28.07.2022 15:36-1const char* p =...;
delete p;
Определите там массив или нет
TheCalligrapher
30.07.2022 02:29В чем смысл этого примера? Все прекрасно поняли, что автор предыдущего комментария имел в виду предлагаемую реализацию, которая каким-то способом сохраняет признак "массив - не массив" при выделении памяти через
new
илиnew []
. В такой реализации, разумеется, нет вообще никакой сложности в том, чтобы определить массив это или нет.
KanuTaH
27.07.2022 19:24+4Ведь один delete вполне мог бы проверять, что он сейчас будет удалять, массив или не массив.
Поскольку в C++ (как и в C) массив объектов может быть "прозрачно" низведен до указателя на объект, то компилятор, рассчитывая только на информацию о типе, не всегда может гарантированно понять, с чем именно он имеет дело в данном конкретном случае - с массивом объектов или с указателем на один объект, без некоей дополнительной информации, которую надо где-то специально хранить, а это идет вразрез с принятой в плюсах идиомой "you don't pay for what you don't use".
TheCalligrapher
28.07.2022 00:25+2Во-первых, да, избежание оверхеда для единичных объектов.
Во-вторых,
delete
обязан уметь выполнять полиморфное удаление, которое совершенно не актуально дляdelete []
. То есть это существенно разные функциональности, калит которые в один оператор было бы неправильно.Ну и как следствие - независимые механизмы перегрузки скрытых за ними операторов выделения сырой памяти.
Также, в третьих, начиная c С++14 реализации имеют право объединять запросы на память между соседними new-expressions, то есть заголовок сырого блока памяти уже не является тривиально доступным из каждого указателя, возвращенного new-expression.Amomum
28.07.2022 01:34delete
обязан уметь выполнять полиморфное удаление, которое совершенно не актуально дляdelete []
.Разве не актуально? Мне казалось, никакой разницы в этом смысле не должно быть
TheCalligrapher
28.07.2022 02:50+1В смысле? В С++ не существует "полиморфных массивов" и не существует полиморфного
delete []
. Вdelete []
не разрешается передавать указатель на базовый класс. Вdelete []
статический тип удаляемого объекта должен совпадать с его динамическим типом. В противном случае поведение не определено.sergio_nsk
28.07.2022 04:49-3Ерунду написали.
delete []
такой же полиморфный, как иdelete
. Он узнает реальный размер через виртуальный деструктор (знаетvtbl
, значит знает тип) первого элемента в массиве, точно так же, как это делаетdelete
для единичного объекта.TheCalligrapher
28.07.2022 05:13+6Это "ерунду" написал не я, а авторы языка С++: дедушка Страуструп и WG21 ISO C++ Committee.
Еще раз повторяю "для тех кто в танке", и не для разглагольствования, а вызубривания наизусть: никакого полиморфного
delete []
в С++ не существует и никогда не существовало. Попытки примененияdelete []
для удаления объекта (или массива), чей статический тип не совпадает с динамическим, приводит к неопределенному поведению. Ни с какими "виртуальными деструкторами", "vtbl" и т.п.delete []
никогда не работает.delete []
не имеет ничего общего с полиморфным поведениемdelete
.P.S. Это "пионэрское" верование в существование некоего "полиморфного
delete []
" я встречаю уже не первый раз. Откуда-то это лезет... Где-то у этого "гнездо"... Я помню, что MSVC++ в старинных версиях своего особенного видения С++ пытался "подарить" пользователям "полиморфныйdelete []
", но даже они со времен прекратили заниматься подобной чушью. Тем не менее эта чушь все живет...Amomum
28.07.2022 15:26+2Я честно признаюсь, что просто не знал. Спасибо!
И я не думаю, что это откуда-то "лезет", просто это ну.. _совершенно нелогично_ ведь! Почему один delete полиморфный, а другой - нет?..
Плюс я часто вижу пример "коллекция указателей на BaseItem" как объяснение зачем вообще нужен полиморфизм; очень странно, что обычный массив при этом себя ведет иначе.
Ну и возможно еще в целом от обычных массивов давно людей отговаривают, поэтому столкнуться с этим уже не так-то легко.
playermet
29.07.2022 12:31+3На самом деле все логично, если немного задуматься.
Когда у нас указатель на один объект, у него может быть какой угодно размер, а полиморфость является штатным ожидаемым поведением.
Когда у нас указатель на массив, мы имеем ряд ограничений. Чтобы выделить память мы должны заранее знать нужный нам размер, что в случае массива равняется произведению размера элемента на количество элементов. Аналогичная ситуация с произвольным доступом к элементам по индексу. Таким образом, элементы массива обязаны иметь одинаковый размер, независимо от того, выделена под него память статически или динамически.
Гипотетически конечно можно было разрешить вставлять в массив элементы разных подтипов пока у них совпадает размер, но это ужасное и небезопасное решение, поэтому было принято что тип всех элементов массива должен совпадать. А раз он совпадает, то и деструктор у всех элементов ровно один и лежит по известному адресу, а значит и полиморфность тут не нужна.
В случаях когда нужен массив полиморфных объектов используется массив указателей на объекты.
Amomum
29.07.2022 17:16Я должен признаться, что совершенно упустил это из виду; я почему-то все это время рассуждал (у себя в голове) о массиве указателей, а не о массиве объектов. Но при удалении массива указателей деструкторы для указываемых объектов вообще не вызываются.
Тогда неясно даже, как оказаться в ситуации, в которой нужен полиморфный delete[]? Взять указатель на массив объектов и скастовать его к указателю на массив объектов другого типа, а потом надеяться на полиморфность?
(Между делом хочу все же отметить, что - по моему мнению - практически на каждую уродливую бородавку в С++ есть логичное объяснение, к сожалению, это не делает язык красивым и консистентным, потому что объяснение это ad hoc и порождают не цельное, так сказать, полотно, а лоскутное одеяло. Это не делает объяснение нелогичным, но порождает фрустрацию из-за необходимости запоминать кучу мелочей)
Amomum
28.07.2022 15:26Для сомневающихся - цитата из стандарта С++11:
5.3.5 Delete
...
2 ... In the second alternative (delete array) if the dynamic type of the object to be deleted differs from its static type, the behavior is undefined.
sergio_nsk
28.07.2022 17:57-4Да абсолютно пофигу. Никогда не пользовался
new[]
иdelete[]
с классами, когда естьstd::vector
. Так что незнание понятно и простительно. А тебя это прям сильно задело.Amomum
28.07.2022 21:58А тебя это прям сильно задело.
На "ты" мы вроде не переходили, но окей; меня не задело, с чего бы, я и сам не знал. Просто цитате (с номером пункта) из стандарта лично у меня веры больше, чем комментарию случайного человека.
playermet
29.07.2022 12:41У std::vector<T> те же самые ограничения что и у T[]. Чтобы использовать полиморфность нужно хранить в массиве не объекты, а указатели на них.
TheCalligrapher
28.07.2022 18:06Но, ЧСХ, и MSVC до сегодняшнего для продолжает поддерживать свой "полиморфный
delete []
", и GCC тоже полез в это болото. В GCCdelete []
с некоторых пор ведет себя по-майкрософтовски.Формально никто им этого, разумеется, не запрещает, ибо поведение все равно не определено.
Из Большой Тройки только в clang не поддерживается "полиморфный
delete []
".То есть неудивительно, что среди программистов "от сохи" может встречаться верование, что
delete []
может использоваться полиморфно.
sergio_nsk
28.07.2022 04:41Потому что можно запросить блок памяти 160 байт и разместить там один объект размером 16 байт. И как
delete
узнает, что там 1 объект, а не 10?delete[]
подсказывает - всё, что выделено, занято объектами, которые нужно удалить.
kai3341
27.07.2022 20:27+5Мне как сочувствующему (не пишу ни на C, ни на C++) было очень познавательно. Спасибо за статью
dyadyaSerezha
27.07.2022 22:58+1но вместо внятного объяснения – просто прикрываются магическим "undefined behavior"
Это кто и где так говорит, интересно? Это же стандартнейший вопрос по С++ на собеседовании. Стыдно не знать.
TheCalligrapher
28.07.2022 00:34+4Тут стоит заметить, что во всех популярных реализациях дополнительное хранение размера массива в
new[]/delete[]
используется только в двух случаях:Элемент массива является классом с нетривиальным деструктором. Размер нужен для вызова правильного количества деструкторов.
Элемент массива является классом с перегруженным
operator delete [](void *ptr, std::size_t size).
Размер нужен для вычисления правильного значения аргумента для параметраsize
.
В остальных случаях дополнительного хранения размера массива в
new[]/delete[]
не производится, т.е. эти операторы выделяют память так же, как голыйmalloc
.Компилятор MSVC++ до последнего времени содержал баг - игнорировал вторую причину из перечисленных выше, в результате чего в нем при вызове перегруженного
operator delete []
в качестве размера передавалось "мусорное" значение. Надо проверить, возможно уже исправили...
Kelbon
28.07.2022 15:37-1-
Не нужно
Ни new ни delete не нужно использовать, перегрузки new подсвечивать красным на анализе, использование new/delete оранжевым new[]/delete[] ярко красным
-
sidorovmax
На практике почти всегда размер выделенной памяти в куче сохранен где-то перед возвращаемым адресом, вне зависимости от функции: malloc, new или new[].
Ибо у блока выделенной памяти должен быть заголовок с размером и указателем на следующий блок.
Исключение может быть в случае специальных технологий оптимизации скорости выделения памяти, вроде заранее выделенных разделов памяти под фиксированные размеры объектов, но и в этом случае принадлежность к конкретному разделу определяет размер объекта.
screwer
Глупости. Никто никому ничего не должен. Посмотрите как работает jemalloc, также очень похожим образом работает slub в линукс-ядре.
Слишком приблизительным образом.
TheCalligrapher
Записанный в начале блока размер совсем не обязательно корректно отражает количество элементов в массиве. То есть деление размера блока на размер элемента в общем случае будет больше или равно количеству элементов массива. А нам нужно знать точное количество элементов.
TheCalligrapher
... Это во-первых,
А во-вторых, что существенно важнее и критичнее, в С++ выделение/освобождение "сырой" памяти изнутри
new[]/delete[]
-выражений выполняют потенциально замещаемые пользователем функцииoperator new[]/operator delete[]
. Что за механизм распределения памяти будет скрываться за этими функциями, где и как он хранит размер выделенного блока и хранит ли вообще - это на уровне ядра языка не известно. По каковой причине никакого доступа к информации о размере блока уdelete[]
-выражения нет и быть не может.