delete, new[] в C++ и городские легенды об их сочетании

    Если в коде на C++ был создан массив объектов с помощью «new[]», удалять этот массив нужно с помощью «delete[]» и ни в коем случае не с помощью «delete» (без скобок). Разумный вопрос: а не то что?

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

    В соответствии со Стандартом C++, в этой ситуации поведение не определено. Все предположения – не более чем популярные городские легенды. Разберем подробно, почему.

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

    class Class {
    public:
            ~Class()
            {
               printf( "Class::~Class()" );
            }
    };
    
    int main()
    {
             delete new Class[1];
             return 0;
    }
    

    Здесь объект в массиве всего один. Если верить любой из двух легенд выше, «все будет хорошо» – утекать нечему и некуда, деструкторов будет вызвано ровно сколько нужно.

    Идем на 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()
    {
             delete new char[1];
             return 0;
    }
    

    Выдача:

    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[]» и самое главное – не тратить зря время на «правдоподобные» объяснения того, что якобы произойдет, если вы их перепутаете. Пока вы будете спорить, другие разработчики будут делать что-то полезное, а для вас будет расти вероятность заслужить премию Дарвина.

    Дмитрий Мещеряков
    Департамент продуктов для разработчиков
    ABBYY
    144.43
    Решения для интеллектуальной обработки информации
    Share post

    Comments 30

      +10
      «Как быть? Смириться, не путать «delete» и «delete[]» и самое главное – не тратить зря время на «правдоподобные» объяснения того, что якобы произойдет, если вы их перепутаете. Пока вы будете спорить, другие разработчики будут делать что-то полезное, а для вас будет расти вероятность заслужить премию Дарвина.»
      Сколько пафоса-то для капитанского объяснения, что delete без скобок для new[] ни к чему хорошему не приведет. И много в Вашей компании программистов, которые с этим утверждением будут спорить?
        –1
        А вы поклянётесь на С++ стандарте, что в коде вашей компании нигде не вызывается неправильный delete?
          +7
          При чем тут это? Опечатка != незнание. А если вдруг пришел кто-то новый и неопытный, и не знает про delete[], сомнительно, что он будет спорить, когда ему об этом скажут.
            0
            но интересно было узнать, почему, то есть я догадывался что так оно и есть, или где-то об этом читал, но теперь знаю точно.
            а вы, если этот новичок спросит, почему, скажете: «не думай, пиши код!», или же предположите почему, как и я, так сейчас же вы сможете ответить точно, а если знали — то относитесь проще, кто-то незнал, почему:)
              0
              Пожалуйста, не забывайте, что описан только один из возможных вариантов развития событий.
        +1
        Надо юзать shared_ptr, если не массив или shared_array для массивов. Тогда вообще не будет вставать вопрос о том, как и главное когда вызывать delete([]).
        Прямым вызовом delete([]) надо пользоваться крайне аккуратно, т.к. с большой вероятностью проглядите какое-нибудь исключение и память утечет.
          +4
          Да, действительно, владеющие указатели и RAII вообще — отличная штука. Только ими нужно пользоваться не «потому что нужно», а с пониманием, какие проблемы они решают. Пост как раз об одной из таких проблем.

          Кстати, вот пример, когда владеющий указатель использован неверно и это привело к дефекту в программе.
            0
            Только ими нужно пользоваться не «потому что нужно», а с пониманием, какие проблемы они решают.

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

            Кстати, вот пример, когда владеющий указатель использован неверно и это привело к дефекту в программе

            Примеров неправильного использования, да еще и в C++, можно на все написать. ;) Они решают одни сложности и вносят свои, но плюсы использования все же многократно перевешивают минусы. Банально, но многие этого не понимают и плодят в своих проектах велосипеды, надеясь на «бесконечную» память, «безошибочные» сторонние библиотеки, собственное совершенство и т.п.
              +1
              Об этом и речь. Скажем, если разработчик не понимает, что делает, то возможна такая ситуация: разработчик написал неверный код (с владеющим указателем или без), статический анализатор ему на это указал, разработчик не пытается понять, в чем на самом деле проблема, и не ищет, как написать правильный код, а вместо этого пытается сделать так, чтобы анализатор заткнулся. Технические средства не отменяют необходимость думать.
            0
            Я согласен с тем, что shared_* — вещь полезная, но всё-таки её надо применять тогда, когда владение объекта не определено, и он действительно «shared». К сожалению, это не garbage collector. В новом стандарте есть unique_ptr, но он реализован на ещё одной новой «фиче» — «rvalue references». Такие можно и в контейнеры класть.
              +1
              но всё-таки её надо применять тогда, когда владение объекта не определено, и он действительно «shared»

              Разумеется.

              Такие можно и в контейнеры класть.

              shared_* тоже можно.
            +1
            По этой причине нельзя использовать std::auto_ptr с массивами:

            std::auto_ptr ptr(new MyClassWithDestructor[32]);
            std::auto_ptr ptrChars(new char[256]);

            Первый пример вряд ли разрушит объект правильно в деструкторе, а второй — скорее всего разрушит нормально для всех известных мне реализаций, но всё равно «это нехорошо». Причём, что прискорбно, часто мы имеем парадигму, где auto_ptr в нормальной ситуации «грабится» с помощью release(). В этом случае только при ошибках, ведущих к исключению мы имеем покоцанную память. В новой редакции стандарта использование auto_ptr не рекомендовано, но это — повод для отдельной статьи
              +1
              Это классика. auto_ptr в деструкторе вызовет delete без скобок, это точно та же ситуация, что в двух примерах в посте. «Это нехорошо» — немного неудачная характеристика. И кстати, вы пытаетесь «объяснить», что произойдет.
                0
                У меня в примеренеправильный синтаксис — не заметил в предосмотре, что хабр выкинул угловые скобки, приняв их за таг. Вот:
                    std::auto_ptr<MyClassWithDestructor> ptr(new MyClassWithDestructor[32]);
                    std::auto_ptr<char> ptrChars(new char[256]);
                +1
                Не тратить зря время на «правдоподобные» объяснения, советуют в конце статьи, состоящей из «правдоподобных» объяснений :)
                  +2
                  В общем, справедливо. Но написать «произойдет ужос, ужос, ваша программа превратит компьютер в черную дыру» — тоже не вариант. Пришлось рассмотреть один пример. Там, кстати, говорится, что это одна из реализаций.
                  0
                  Наверное, стоит добавить, что место для количества элементов выделяется с учетом выравнивания структуры.
                  Т.е., типично добавляется sizeof (unsigned int) (4 байта). Но, если объект объявлен с __declspec(align(16)), или в нем есть SSE-шное поле, то дополнительно выделяется не 4, а 16 байт. При этом количество элементов помещается в начало дополнительного блока (первые четыре байта). (Это для MSVC. Как это сделано в GCC, смотреть еще не доводилось).
                    0
                    Давненько, когда начинал изучать С++ по русским учебникам (по сути голый С с фишками С++). Юзал и сам new[] и delete. Когда устроился на работу сразу научили и все разъяснили. В итоге теперь со своей стороны таких проблем нет, вообще использую довольно редко выделения массивов, для этих целей чаще контейнеры.

                    А вообще особого смысла не вижу в статье, уж простите, т.к. поведение зависит от компилятора и изучать какой-то один для того, чтобы понять механизм нет никакого смысла. А по поводу new/delete и new[]/delete[] уже очень много понаписано, лучше просто собрать список различных фейлов, по типу данного оператора, неправельности перегрузки, отсутствия конструктора копий и т.д…
                      0
                      Спасибо за статью! Для глубокого понимания предмета хороших антипримеров никогда не бывает мало.
                        0
                        Зачем вообще использовать new[] когда есть вектор?
                        Он и расширяется сам и управляет памятью сам и легко получить указатель на память.
                        Зачем использовать опасные методы (обычный указатель на ресурс) там, где есть хорошее безопасное решение?
                          +4
                          Да, вы правы, во многих случаях нужно просто использовать vector. Один из случаев, когда vector неудобен, — выделение памяти для последующей передачи ее во владение сторонней библиотеке вроде zlib.
                            +1
                            Вы правы в случае, если вы пишете high-level логику или нечто в этом роде.
                            ИМХО тем, кто пишет low-level код, использование stl-ные штук нужно просто запретить
                            +1
                            Спасибо. Более десяти лет программирую на C++, правило помню — но что там детально происходит память не держала. Статья очень хорошо написано, так что, надеюсь, теперь буду помнить не только «как», но и «почему» :).
                              0
                              спасибо за статью
                              надо бы ее перенести в профильный Блог про С++
                              там на нее больше народу обратит внимание.

                              А вот использование new[] & delete — любимый конек ловушек на тестирвоание С++ прграммистов.
                                –1
                                Вот на этой строке: «В соответствии со Стандартом C++, в этой ситуации поведение не определено» статью надо было заканчивать. Всё, что идёт ниже — вода.
                                  +1
                                  Все равно, что сказать «так делать нельзя» — верно, но неубедительно и может привести к печальным последствиям.
                                    +2
                                    Существенная часть статьи — подробное описание различных undefined behaviour. Зачем?

                                    — Больной перед смертью потел?
                                    — Потел.
                                    — Это хорошо.
                                  0
                                  Зачем такие бессмысленные посты?
                                  Столько букв чтобы сказать: неправильный delete = undefined behavior
                                    +4
                                    Да, прекрасно. Вот определение UB (1.3.12): «поведение, которое может возникнуть при использовании ошибочного кода или ошибочных данных, на которое этот Международный Стандарт не накладывает никаких требований».

                                    Это определение безупречно, но очень абстрактно. Как его понимать разработчику, который недавно пишет на C++? «Не делай так или тебя уволят»? Или, может быть, «если так сделать, будет плохо»?
                                    0
                                    Note: permissible undefined behavior ranges from ignoring the situation completely with unpredictable results, to behaving during
                                    translation or program execution in a documented manner characteristic of the environment (with or without
                                    the issuance of a diagnostic message), to terminating a translation or execution (with the issuance of a
                                    diagnostic message). Many erroneous program constructs do not engender undefined behavior; they are required to be diagnosed.

                                    Чего тут не понять?

                                    Only users with full accounts can post comments. Log in, please.