Для получения триального ключа
заполните форму ниже
Team License (базовая версия)
Enterprise License (расширенная версия)
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Запросите информацию о ценах
Новая лицензия
Продление лицензии
--Выберите валюту--
USD
EUR
RUB
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Бесплатная лицензия PVS-Studio для специалистов Microsoft MVP
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Для получения лицензии для вашего открытого
проекта заполните, пожалуйста, эту форму
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Мне интересно попробовать плагин на:
* Нажимая на кнопку, вы даете согласие на обработку
своих персональных данных. См. Политику конфиденциальности

Ваше сообщение отправлено.

Мы ответим вам на


Если вы так и не получили ответ, пожалуйста, проверьте папку
Spam/Junk и нажмите на письме кнопку "Не спам".
Так Вы не пропустите ответы от нашей команды.

>
>
>
Почему в С++ массивы нужно удалять чере…

Почему в С++ массивы нужно удалять через delete[]

27 Июл 2022

Заметка рассчитана на начинающих C++ программистов, которым стало интересно, почему везде твердят, что нужно использовать delete[] для массивов, но вместо внятного объяснения – просто прикрываются магическим "undefined behavior". Немного кода, несколько картинок и взгляд под капот компиляторов – всех заинтересованных прошу под кат.

0973_delete_or_delete_for_array_ru/image1.png

Введение

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

int *p = new SomeClass[42];  // Указываем количество
delete[] p;                  // Не указываем количество

Это что, магия? Отчасти – да. Причём разработчики различных компиляторов видят и реализуют её по-разному.

0973_delete_or_delete_for_array_ru/image2.png

Существует два основных подхода к тому, как компиляторы запоминают количество элементов в массиве:

  • Запись количества элементов перед самим массивом ("Over-Allocation")
  • Хранение количества элементов в обособленном ассоциативном контейнере ("Associative Array")

Over-Allocation

Первый способ, как понятно из названия, реализуется простой записью количества элементов перед массивом. Обратите внимание, что в таком случае указатель, который вы получите после выполнения оператора new, будет указывать на первый элемент массива, а не на его фактическое начало.

0973_delete_or_delete_for_array_ru/image3.png

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

0973_delete_or_delete_for_array_ru/image4.png

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

Примечание: пустой деструктор у структуры – это отнюдь не лишний код. Дело в том, что согласно 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. 😊

Если же вас заинтересовала тема неопределённого поведения и особенностей работы компиляторов, то могу посоветовать ещё парочку дополнительных материалов:

Популярные статьи по теме
Межмодульный анализ C и C++ проектов в деталях. Часть 2

Дата: 14 Июл 2022

Автор: Олег Лысый

В первой части статьи мы рассматривали основы теории компиляции C и C++ проектов, в частности особое внимание уделили алгоритмам компоновки и оптимизациям. Во второй части мы погрузимся глубже и пока…
Межмодульный анализ C и C++ проектов в деталях. Часть 1

Дата: 08 Июл 2022

Автор: Олег Лысый

Начиная с PVS-Studio 7.14, для C и C++ анализатора появилась поддержка межмодульного анализа. В этой статье, которая будет состоять из двух частей, мы расскажем, как устроены похожие механизмы в комп…
Четыре причины проверять, что вернула функция malloc

Дата: 20 Апр 2022

Автор: Андрей Карпов

Некоторые разработчики пренебрежительно относятся к проверкам: удалось ли выделить память с помощью функции malloc или нет. Их логика проста – памяти всегда должно хватить. А если не хватит, всё равн…
Атака Trojan Source: скрытые уязвимости

Дата: 15 Апр 2022

Автор: Гость

Мы представляем новый тип атаки для внедрения в исходный код вредоносных изменений, по-разному выглядящих для компилятора и человека. Такая атака эксплуатирует тонкости стандартов кодирования символо…
Дизайн и эволюция constexpr в C++

Дата: 13 Янв 2022

Автор: Гость

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

Комментарии (0)

Следующие комментарии
Unicorn with delicious cookie
Мы используем куки, чтобы пользоваться сайтом было удобно. Хотите узнать подробнее?
Принять