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


delete_or_delete_for_array_ru/image1.png


Введение


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


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

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


delete_or_delete_for_array_ru/image2.png


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


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

Over-Allocation


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


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.


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. ????

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


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


  1. sidorovmax
    27.07.2022 16:37
    -4

    На практике почти всегда размер выделенной памяти в куче сохранен где-то перед возвращаемым адресом, вне зависимости от функции: malloc, new или new[].
    Ибо у блока выделенной памяти должен быть заголовок с размером и указателем на следующий блок.
    Исключение может быть в случае специальных технологий оптимизации скорости выделения памяти, вроде заранее выделенных разделов памяти под фиксированные размеры объектов, но и в этом случае принадлежность к конкретному разделу определяет размер объекта.


    1. screwer
      27.07.2022 16:44
      +5

      Ибо у блока выделенной памяти должен быть заголовок с размером и указателем на следующий блок.

      Глупости. Никто никому ничего не должен. Посмотрите как работает jemalloc, также очень похожим образом работает slub в линукс-ядре.

      в этом случае принадлежность к конкретному разделу определяет размер объекта.

      Слишком приблизительным образом.


    1. TheCalligrapher
      28.07.2022 00:19
      +1

      Записанный в начале блока размер совсем не обязательно корректно отражает количество элементов в массиве. То есть деление размера блока на размер элемента в общем случае будет больше или равно количеству элементов массива. А нам нужно знать точное количество элементов.


      1. TheCalligrapher
        30.07.2022 07:37

        ... Это во-первых,

        А во-вторых, что существенно важнее и критичнее, в С++ выделение/освобождение "сырой" памяти изнутри new[]/delete[]-выражений выполняют потенциально замещаемые пользователем функции operator new[]/operator delete[]. Что за механизм распределения памяти будет скрываться за этими функциями, где и как он хранит размер выделенного блока и хранит ли вообще - это на уровне ядра языка не известно. По каковой причине никакого доступа к информации о размере блока у delete[]-выражения нет и быть не может.


  1. Amomum
    27.07.2022 18:14
    +1

    Интересно, а почему вообще были введены эти два оператора? Ведь один delete вполне мог бы проверять, что он сейчас будет удалять, массив или не массив.

    Желание снизить оверхед для хранения не-массивов?


    1. screwer
      27.07.2022 18:38
      +3

      А как он будет это проверять ? А почему именно так, может я по-другому хочу ?

      Вот чтобы была возможность сделать свою реализацию - разрешили перегрузить оба варианта.


      1. Amomum
        27.07.2022 20:18
        +1

        Как - это была бы забота компилятора, так же как сейчас


        1. KyHTEP
          28.07.2022 04:38

          Слишком категорично, не находите?

          Если задача не решаема или в заданных условиях решаема не оптимально, то это проблема постановщика задачи, а не компилятора. (Иди туда - не знаю куда, делай то - не знаю что)

          На каждый delete чекать принадлежность к массиву или нет - это не оверхед, это брейндемедж ) Разделение операторов - это выбор меньшего из зол.

          Тем более, что частота использования "сырых" массивов в прикладных задачах, вместо, хотя бы, std::vector, минимальна.
          В геймдеве, например, вообще была бы связка malloc+placement_new(optional), а то просто загрузка всех данных из файла с коррекцией указателей.


          1. Amomum
            28.07.2022 16:34
            +2

            На каждый delete чекать принадлежность к массиву или нет - это не оверхед, это брейндемедж ) Разделение операторов - это выбор меньшего из зол.

            Имхо это скорее обычная практика С/С++ - перекладывать оверхед на программиста; я просто хотел убедиться, что причина именно в этом.


        1. Kelbon
          28.07.2022 15:36
          -1

          const char* p =...;

          delete p;

          Определите там массив или нет


          1. TheCalligrapher
            30.07.2022 02:29

            В чем смысл этого примера? Все прекрасно поняли, что автор предыдущего комментария имел в виду предлагаемую реализацию, которая каким-то способом сохраняет признак "массив - не массив" при выделении памяти через new или new []. В такой реализации, разумеется, нет вообще никакой сложности в том, чтобы определить массив это или нет.


    1. KanuTaH
      27.07.2022 19:24
      +4

      Ведь один delete вполне мог бы проверять, что он сейчас будет удалять, массив или не массив.

      Поскольку в C++ (как и в C) массив объектов может быть "прозрачно" низведен до указателя на объект, то компилятор, рассчитывая только на информацию о типе, не всегда может гарантированно понять, с чем именно он имеет дело в данном конкретном случае - с массивом объектов или с указателем на один объект, без некоей дополнительной информации, которую надо где-то специально хранить, а это идет вразрез с принятой в плюсах идиомой "you don't pay for what you don't use".


    1. TheCalligrapher
      28.07.2022 00:25
      +2

      Во-первых, да, избежание оверхеда для единичных объектов.

      Во-вторых, delete обязан уметь выполнять полиморфное удаление, которое совершенно не актуально для delete []. То есть это существенно разные функциональности, калит которые в один оператор было бы неправильно.

      Ну и как следствие - независимые механизмы перегрузки скрытых за ними операторов выделения сырой памяти.

      Также, в третьих, начиная c С++14 реализации имеют право объединять запросы на память между соседними new-expressions, то есть заголовок сырого блока памяти уже не является тривиально доступным из каждого указателя, возвращенного new-expression.


      1. Amomum
        28.07.2022 01:34

        delete обязан уметь выполнять полиморфное удаление, которое совершенно не актуально для delete [].

        Разве не актуально? Мне казалось, никакой разницы в этом смысле не должно быть


        1. TheCalligrapher
          28.07.2022 02:50
          +1

          В смысле? В С++ не существует "полиморфных массивов" и не существует полиморфного delete []. В delete [] не разрешается передавать указатель на базовый класс. В delete [] статический тип удаляемого объекта должен совпадать с его динамическим типом. В противном случае поведение не определено.


          1. sergio_nsk
            28.07.2022 04:49
            -3

            Ерунду написали. delete [] такой же полиморфный, как и delete. Он узнает реальный размер через виртуальный деструктор (знает vtbl, значит знает тип) первого элемента в массиве, точно так же, как это делает delete для единичного объекта.


            1. TheCalligrapher
              28.07.2022 05:13
              +6

              Это "ерунду" написал не я, а авторы языка С++: дедушка Страуструп и WG21 ISO C++ Committee.

              Еще раз повторяю "для тех кто в танке", и не для разглагольствования, а вызубривания наизусть: никакого полиморфного delete [] в С++ не существует и никогда не существовало. Попытки применения delete [] для удаления объекта (или массива), чей статический тип не совпадает с динамическим, приводит к неопределенному поведению. Ни с какими "виртуальными деструкторами", "vtbl" и т.п. delete [] никогда не работает.

              delete [] не имеет ничего общего с полиморфным поведением delete.

              P.S. Это "пионэрское" верование в существование некоего "полиморфного delete []" я встречаю уже не первый раз. Откуда-то это лезет... Где-то у этого "гнездо"... Я помню, что MSVC++ в старинных версиях своего особенного видения С++ пытался "подарить" пользователям "полиморфный delete []", но даже они со времен прекратили заниматься подобной чушью. Тем не менее эта чушь все живет...


              1. Amomum
                28.07.2022 15:26
                +2

                Я честно признаюсь, что просто не знал. Спасибо!

                И я не думаю, что это откуда-то "лезет", просто это ну.. _совершенно нелогично_ ведь! Почему один delete полиморфный, а другой - нет?..

                Плюс я часто вижу пример "коллекция указателей на BaseItem" как объяснение зачем вообще нужен полиморфизм; очень странно, что обычный массив при этом себя ведет иначе.

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


                1. playermet
                  29.07.2022 12:31
                  +3

                  На самом деле все логично, если немного задуматься.

                  Когда у нас указатель на один объект, у него может быть какой угодно размер, а полиморфость является штатным ожидаемым поведением.

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

                  Гипотетически конечно можно было разрешить вставлять в массив элементы разных подтипов пока у них совпадает размер, но это ужасное и небезопасное решение, поэтому было принято что тип всех элементов массива должен совпадать. А раз он совпадает, то и деструктор у всех элементов ровно один и лежит по известному адресу, а значит и полиморфность тут не нужна.

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


                  1. Amomum
                    29.07.2022 17:16

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

                    Тогда неясно даже, как оказаться в ситуации, в которой нужен полиморфный delete[]? Взять указатель на массив объектов и скастовать его к указателю на массив объектов другого типа, а потом надеяться на полиморфность?

                    (Между делом хочу все же отметить, что - по моему мнению - практически на каждую уродливую бородавку в С++ есть логичное объяснение, к сожалению, это не делает язык красивым и консистентным, потому что объяснение это ad hoc и порождают не цельное, так сказать, полотно, а лоскутное одеяло. Это не делает объяснение нелогичным, но порождает фрустрацию из-за необходимости запоминать кучу мелочей)


            1. 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.


              1. sergio_nsk
                28.07.2022 17:57
                -4

                Да абсолютно пофигу. Никогда не пользовался new[] и delete[] с классами, когда есть std::vector. Так что незнание понятно и простительно. А тебя это прям сильно задело.


                1. Amomum
                  28.07.2022 21:58

                   А тебя это прям сильно задело.

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


                  1. sergio_nsk
                    28.07.2022 22:27
                    -1

                    Прошу прощения, не тому ответил. Хотел ответить TheCalligrapher.


                1. playermet
                  29.07.2022 12:41

                  У std::vector<T> те же самые ограничения что и у T[]. Чтобы использовать полиморфность нужно хранить в массиве не объекты, а указатели на них.


              1. TheCalligrapher
                28.07.2022 18:06

                Но, ЧСХ, и MSVC до сегодняшнего для продолжает поддерживать свой "полиморфный delete []", и GCC тоже полез в это болото. В GCC delete [] с некоторых пор ведет себя по-майкрософтовски.

                Формально никто им этого, разумеется, не запрещает, ибо поведение все равно не определено.

                Из Большой Тройки только в clang не поддерживается "полиморфный delete [] ".

                То есть неудивительно, что среди программистов "от сохи" может встречаться верование, что delete [] может использоваться полиморфно.


                1. Amomum
                  28.07.2022 22:01

                  Допускаю, но я лично не на MSVC "воспитан"


    1. sergio_nsk
      28.07.2022 04:41

      Потому что можно запросить блок памяти 160 байт и разместить там один объект размером 16 байт. И как delete узнает, что там 1 объект, а не 10? delete[] подсказывает - всё, что выделено, занято объектами, которые нужно удалить.


  1. kai3341
    27.07.2022 20:27
    +5

    Мне как сочувствующему (не пишу ни на C, ни на C++) было очень познавательно. Спасибо за статью


  1. dyadyaSerezha
    27.07.2022 22:58
    +1

    но вместо внятного объяснения – просто прикрываются магическим "undefined behavior"

    Это кто и где так говорит, интересно? Это же стандартнейший вопрос по С++ на собеседовании. Стыдно не знать.


  1. TheCalligrapher
    28.07.2022 00:34
    +4

    Тут стоит заметить, что во всех популярных реализациях дополнительное хранение размера массива в new[]/delete[] используется только в двух случаях:

    • Элемент массива является классом с нетривиальным деструктором. Размер нужен для вызова правильного количества деструкторов.

    • Элемент массива является классом с перегруженным operator delete [](void *ptr, std::size_t size).Размер нужен для вычисления правильного значения аргумента для параметраsize.

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

    Компилятор MSVC++ до последнего времени содержал баг - игнорировал вторую причину из перечисленных выше, в результате чего в нем при вызове перегруженного operator delete [] в качестве размера передавалось "мусорное" значение. Надо проверить, возможно уже исправили...


  1. Kelbon
    28.07.2022 15:37
    -1

    1. Не нужно

      Ни new ни delete не нужно использовать, перегрузки new подсвечивать красным на анализе, использование new/delete оранжевым new[]/delete[] ярко красным


  1. Urub
    28.07.2022 19:16

    а зачем он нужен этот "сырой" массив если есть, например, std контейнеры ?