Как устроены операторы delete и delete[]?
Сможет ли оператор delete корректно удалить объект через бестиповый указатель? То есть, использует ли оператор delete информацию, доступную во время компиляции?
Отслеживать
задан 17 апр 2020 в 13:05
4,128 1 1 золотой знак 9 9 серебряных знаков 23 23 бронзовых знака
1 ответ 1
Сортировка: Сброс на вариант по умолчанию
оператор delete вызывает деструктор, основываясь на статическом типе (тот, который он видит по объявлению указателя) и динамическом (если есть такая информация). После этого он вызывает менеджер памяти, что бы все освободить эту память. В этом случае тип указателя уже не имеет значения.
Если в delete приходит указатель «неверного типа», то будет вызван и «неверный деструктор». В некоторых случаях это может закончится плачевно. У void деструктор фактически отсутствует, значит вызывать нечего и соответственно ничего не вызовется. Если деструктор выполняет нетривиальную работу — будет не очень хорошо.
delete[] получив указатель, «магическим способом» получает размер массива (обычно, он хранится рядом с указателем, но это личное дело компилятора, поэтому я и написал «магическим способом»). А дальше просто в цикле вызываем delete . Ничего необычного. Поэтому, если попутать delete и delete[] могут быть разные непонятные баги ( delete[] будет искать размер массива там, где его нет, а потом пойдет в разнос).
Некоторые компиляторы могут добавлять немножко больше кода для проверки себя и удалять корректно. К примеру, так может сделать visual studio в дебажном режиме. Но я бы на это не закладывался.
delete, new[] в C++ и городские легенды об их сочетании
Если в коде на C++ был создан массив объектов с помощью «new[]», удалять этот массив нужно с помощью «delete[]» и ни в коем случае не с помощью «delete» (без скобок). Разумный вопрос: а не то что?
На этот вопрос можно получить широчайший спектр неразумных ответов. Например, «будет удален только первый объект, остальные утекут» или «будет вызван деструктор только первого объекта». Следующие за этим «объяснения» не выдерживают обычно никакой серьезной критики.
В соответствии со Стандартом C++, в этой ситуации поведение не определено. Все предположения – не более чем популярные городские легенды. Разберем подробно, почему.
Нам понадобится хитрый план с примером, который бы ставил в тупик сторонников городских легенд. Вот такой безобидный будет ок:
class Class < public: ~Class() < printf( "Class::~Class()" ); >>; int main()
Здесь объект в массиве всего один. Если верить любой из двух легенд выше, «все будет хорошо» – утекать нечему и некуда, деструкторов будет вызвано ровно сколько нужно.
Идем на codepad.org, вставляем код в форму, получаем выдачу:
memory clobbered before allocated block Exited: ExitFailure 127 42 75 67 20 61 73 73 61 73 73 69 6E 20 77 61 6E 74 65 64 20 2D 20 77 77 77 2E 61 62 62 79 79 2E 72 75 2F 76 61 63 61 6E 63 79
MEMORY WHAT. Что это было?
int main()
No errors or program output.
Здесь хотя бы с виду все хорошо. Что происходит? Почему так происходит? Почему поведение с виду разное?
Причина в том, что происходит внутри.
Когда в коде встречается «new Type[count]», программа обязана выделить память объема, достаточного для хранения указанного числа объектов. Для этого она использует функцию «operator new[]()». Эта функция выделяет память – обычно внутри просто вызов malloc() и проверка возвращаемого значения (при необходимости – вызов new_handler() и выброс исключения). Затем в выделенной памяти конструируются объекты – вызывается нужное число конструкторов. Результатом «new Type[count]» является адрес первого элемента массива.
Когда в коде встречается «delete[] pointer», программа должна разрушить все объекты в массиве, вызвав для них деструкторы. Для этого (и только для этого) ей нужно знать число элементов.
Важный момент: в конструкции «new Type[count]» число элементов было указано явно, а «delete[]» получает только адрес первого элемента.
Откуда программа узнает число элементов? Раз у нее есть только адрес первого элемента, она должна вычислить длину массива на основании одного этого адреса. Как это делается, зависит от реализации, обычно используется следующий способ.
При выполнении «new Type[count]» программа выделяет памяти столько, чтобы в нее поместились не только объекты, но и беззнаковое целое (обычно типа size_t), обозначающее число объектов. В начало выделенной области пишется это число, дальше размещаются объекты. Компилятор при компиляции «new Type[count]» вставляет в программу код, который реализует эти свистелки.
Итак, при выполнении «new Type[count]» программа выделяет чуть больше памяти, записывает число элементов в начало выделенного блока памяти, вызывает конструкторы и возвращает вызывающему коду адрес первого элемента. Адрес первого элемента будет отличаться от адреса, который возвратила функция выделения памяти «operator new[]()».
При выполнении «delete[]» программа берет адрес первого элемента, переданный в «delete[]», определяет адрес начала блока (вычитая ровно столько же, сколько было прибавлено при выполнении «new[]»), читает число элементов из начала блока, вызывает нужное число деструкторов, затем – вызывает функцию «operator delete[]()», передав ей адрес начала блока.
В обоих случаях вызывающий код работает не с тем адресом, который был возвращен функцией выделения памяти и позже – передан функции освобождения памяти.
Теперь вернемся к первому примеру. Когда выполняется «delete» (без скобок), вызывающий код понятия не имеет, что нужно проиграть последовательность со смещением адреса. Скорее всего, он вызывает деструктор единственного объекта, затем передает в функцию «operator delete()» адрес, который отличается от ранее возвращенного функцией «operator new[]()».
Что должно произойти? В этой реализации программа аварийно завершается. Поскольку Стандарт говорит, что поведение не определено, это допустимо.
Для сравнения, программа на Visual C++ 9 по умолчанию исходит сообщениями об ошибках в отладочной версии, но вроде бы нормально отрабатывает (по крайней мере, функция _heapchk() возвращает код _HEAP_OK, _CrtDumpMemoryLeaks() не выдает никаких сообщений). Это тоже допустимо.
Почему во втором примере поведение другое? Скорее всего, компилятор учел, что у типа char тривиальный деструктор, т.е. не нужно ничего делать для разрушения объектов, а достаточно просто освободить память, поэтому и число элементов хранить не нужно, а значит, можно сразу вернуть вызывающему коду тот же адрес, который вернула функция «operator new[]()». Никаких смещений адреса – точно так же, как и при вызове «new» (без скобок). Такое поведение компилятора полностью соответствует Стандарту.
Чего-то не хватает…
Вы уже заметили, что выше по тексту встречаются функции выделения и освобождения памяти то с квадратными скобками, то без? Это не опечатки – это две разные пары функций, они могут быть реализованы совершенно по-разному. Даже когда компилятор пытается сэкономить, он всегда вызывает функцию «operator new[]()», когда видит в коде «new Type[count]», и всегда вызывает функцию «operator new()», когда видит в коде «new Type».
Обычно реализации функций «operator new()» и «operator new[]()» одинаковы (обе вызывают malloc()), но их можно заменить – определить свои, причем можно заменить как одну пару, так и обе, также можно заменять эти функции по отдельности для любого выбранного класса. Стандарт позволяет это делать сколько угодно (естественно, нужно адекватно заменить парную функцию освобождения памяти).
Это дает богатые возможности для неопределенного поведения. Если ваш код приводит к тому, что память освобождается «не той» функцией, это может приводить к любым последствиям, в частности, к повреждению кучи, порче памяти или немедленному аварийному завершению программы. В первом примере реализация функции «operator delete()» не смогла распорядиться переданным ей адресом и программа аварийно завершилась.
Самая приятная часть этого рассказа – вы никогда не сможете утверждать, что использование «delete» вместо «delete[]» (и наоборот – тоже) приводит к какому-то конкретному результату. Стандарт говорит, что поведение не определено. Даже полностью соответствующий Стандарту компилятор не обязан выдать вам программу с каким-либо адекватным поведением. Поведение программы, на которое вы будете ссылаться в комментариях и спорах, является только наблюдаемым – внутри может происходить все что угодно. Вы только констатируете наблюдаемое вами поведение.
Во втором примере с виду все хорошо… на этой реализации. На другой реализации функции «operator new()» и «operator new[]()» могут быть, например, реализованы на разных кучах (Windows позволяет создавать более одной кучи на процесс). Что произойдет при попытке возвратить блок «не в ту» кучу?
Кстати, рассчитывая на какое-то конкретное поведение в этой ситуации, вы автоматически получаете непереносимый код. Даже если на текущей реализации «все работает», при переходе на другой компилятор, при смене версии компилятора или даже при обновлении C++ runtime вы можете быть крайне неприятно удивлены.
Как быть? Смириться, не путать «delete» и «delete[]» и самое главное – не тратить зря время на «правдоподобные» объяснения того, что якобы произойдет, если вы их перепутаете. Пока вы будете спорить, другие разработчики будут делать что-то полезное, а для вас будет расти вероятность заслужить премию Дарвина.
Дмитрий Мещеряков
Департамент продуктов для разработчиков
Оператор DELETE стр. 1
Оператор DELETE удаляет строки из временных или постоянных базовых таблиц, представлений или курсоров, причем в двух последних случаях действие оператора распространяется на те базовые таблицы, из которых извлекались данные в эти представления или курсоры. Оператор удаления имеет простой синтаксис:

Если предложение WHERE отсутствует, удаляются все строки из таблицы или представления (представление должно быть обновляемым). Более быстро эту операцию (удаление всех строк из таблицы) можно в Transact-SQL (T-SQL) — процедурное расширение языка SQL, используемое для программирования на стороне сервера в Microsoft SQL Server и Sybase ASE. Transact-SQL также выполнить с помощью команды
Однако есть ряд особенностей в реализации команды TRUNCATE TABLE , которые следует иметь в виду:
- не журнализируется удаление отдельных строк таблицы; в журнал записывается только освобождение страниц, которые были заняты данными таблицы;
- не отрабатывают триггеры, в частности, триггер на удаление;
- команда неприменима, если на данную таблицу имеется ссылка по внешнему ключу, и даже если внешний ключ имеет опцию каскадного удаления.
- значение счетчика ( IDENTITY ) сбрасывается в начальное значение.
Требуется удалить из таблицы Laptop все портативные компьютеры с размером экрана менее 12 дюймов.
Все блокноты можно удалить с помощью оператора
Transact-SQL расширяет синтаксис оператора DELETE , вводя дополнительное предложение FROM :
При помощи источника табличного типа можно конкретизировать данные, удаляемые из таблицы в первом предложении FROM .
При помощи этого предложения можно выполнять соединения таблиц, что логически заменяет использование подзапросов в предложении WHERE для идентификации удаляемых строк. Поясним сказанное на примере.
Пусть требуется удалить те модели ПК из таблицы Product, для которых нет соответствующих строк в таблице PC.
Используя стандартный синтаксис, эту задачу можно решить следующим запросом:
Заметим, что предикат type = ‘pc’ необходим здесь, чтобы не были удалены также модели принтеров и портативных компьютеров.
Эту же задачу можно решить с помощью дополнительного предложения FROM следующим образом:
Здесь применяется внешнее соединение, в результате чего столбец PC.model для моделей ПК, отсутствующих в таблице PC, будет содержать NULL -значение, что и используется для идентификации подлежащих удалению строк.
| Страницы: | 1 | 2 | 3 |
Почему в С++ массивы нужно удалять через delete[]
Заметка рассчитана на начинающих 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
- Блог компании PVS-Studio
- Программирование
- C++