Урок №85. Динамическое выделение памяти

  Юрий  | 

  |

  Обновл. 13 Сен 2021  | 

 153490

 ǀ   34 

Язык С++ поддерживает три основных типа выделения (или «распределения») памяти, с двумя из которых, мы уже знакомы:

   Статическое выделение памяти выполняется для статических и глобальных переменных. Память выделяется один раз (при запуске программы) и сохраняется на протяжении работы всей программы.

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

   Динамическое выделение памяти является темой этого урока.

Динамическое выделение переменных

Как статическое, так и автоматическое распределение памяти имеют два общих свойства:

   Размер переменной/массива должен быть известен во время компиляции.

   Выделение и освобождение памяти происходит автоматически (когда переменная создается/уничтожается).

В большинстве случаев с этим всё ОК. Однако, когда дело доходит до работы с пользовательским вводом, то эти ограничения могут привести к проблемам.

Например, при использовании строки для хранения имени пользователя, мы не знаем наперед насколько длинным оно будет, пока пользователь его не введет. Или нам нужно создать игру с непостоянным количеством монстров (во время игры одни монстры умирают, другие появляются, пытаясь, таким образом, убить игрока).

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

Это плохое решение, по крайней мере, по трем причинам:

Во-первых, теряется память, если переменные фактически не используются или используются, но не все. Например, если мы выделим 30 символов для каждого имени, но имена в среднем будут занимать по 15 символов, то потребление памяти получится в два раза больше, чем нам нужно на самом деле. Или рассмотрим массив rendering: если он использует только 20 000 полигонов, то память для других 20 000 полигонов фактически тратится впустую (т.е. не используется)!

Во-вторых, память для большинства обычных переменных (включая фиксированные массивы) выделяется из специального резервуара памяти — стека. Объем памяти стека в программе, как правило, невелик: в Visual Studio он по умолчанию равен 1МБ. Если вы превысите это значение, то произойдет переполнение стека, и операционная система автоматически завершит выполнение вашей программы.

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

Лимит в 1МБ памяти может быть проблематичным для многих программ, особенно где используется графика.

В-третьих, и самое главное, это может привести к искусственным ограничениям и/или переполнению массива. Что произойдет, если пользователь попытается прочесть 500 записей с диска, но мы выделили память максимум для 400? Либо мы выведем пользователю ошибку, что максимальное количество записей — 400, либо (в худшем случае) выполнится переполнение массива и затем что-то очень нехорошее.

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

Для динамического выделения памяти одной переменной используется оператор new:

В примере, приведенном выше, мы запрашиваем выделение памяти для целочисленной переменной из операционной системы. Оператор new возвращает указатель, содержащий адрес выделенной памяти.

Для доступа к выделенной памяти создается указатель:

Затем мы можем разыменовать указатель для получения значения:

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

Как работает динамическое выделение памяти?


На вашем компьютере имеется память (возможно, большая её часть), которая доступна для использования программами. При запуске программы ваша операционная система загружает эту программу в некоторую часть этой памяти. И эта память, используемая вашей программой, разделена на несколько частей, каждая из которых выполняет определенную задачу. Одна часть содержит ваш код, другая используется для выполнения обычных операций (отслеживание вызываемых функций, создание и уничтожение глобальных и локальных переменных и т.д.). Мы поговорим об этом чуть позже. Тем не менее, большая часть доступной памяти компьютера просто находится в ожидании запросов на выделение от программ.

Когда вы динамически выделяете память, то вы просите операционную систему зарезервировать часть этой памяти для использования вашей программой. Если ОС может выполнить этот запрос, то возвращается адрес этой памяти обратно в вашу программу. С этого момента и в дальнейшем ваша программа сможет использовать эту память, как только пожелает. Когда вы уже выполнили с этой памятью всё, что было необходимо, то её нужно вернуть обратно в операционную систему, для распределения между другими запросами.

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

Освобождение памяти

Когда вы динамически выделяете переменную, то вы также можете её инициализировать посредством прямой инициализации или uniform-инициализации (в С++11):

Когда уже всё, что требовалось, выполнено с динамически выделенной переменной — нужно явно указать для С++ освободить эту память. Для переменных это выполняется с помощью оператора delete:

Оператор delete на самом деле ничего не удаляет. Он просто возвращает память, которая была выделена ранее, обратно в операционную систему. Затем операционная система может переназначить эту память другому приложению (или этому же снова).

Хотя может показаться, что мы удаляем переменную, но это не так! Переменная-указатель по-прежнему имеет ту же область видимости, что и раньше, и ей можно присвоить новое значение, как и любой другой переменной.

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

Висячие указатели


Язык C++ не предоставляет никаких гарантий относительно того, что произойдет с содержимым освобожденной памяти или со значением удаляемого указателя. В большинстве случаев, память, возвращаемая операционной системе, будет содержать те же значения, которые были у нее до освобождения, а указатель так и останется указывать на только что освобожденную (удаленную) память.

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

В программе, приведенной выше, значение 8, которое ранее было присвоено динамической переменной, после освобождения может и далее находиться там, а может и нет. Также возможно, что освобожденная память уже могла быть выделена другому приложению (или для собственного использования операционной системы), и попытка доступа к ней приведет к тому, что операционная система автоматически прекратит выполнение вашей программы.

Процесс освобождения памяти может также привести и к созданию нескольких висячих указателей. Рассмотрим следующий пример:

Есть несколько рекомендаций, которые могут здесь помочь:

   Во-первых, старайтесь избегать ситуаций, когда несколько указателей указывают на одну и ту же часть выделенной памяти. Если это невозможно, то выясните, какой указатель из всех «владеет» памятью (и отвечает за её удаление), а какие указатели просто получают доступ к ней.

   Во-вторых, когда вы удаляете указатель, и, если он не выходит из области видимости сразу же после удаления, то его нужно сделать нулевым, т.е. присвоить значение 0 (или nullptr в С++11). Под «выходом из области видимости сразу же после удаления» имеется в виду, что вы удаляете указатель в самом конце блока, в котором он объявлен.

Правило: Присваивайте удаленным указателям значение 0 (или nullptr в C++11), если они не выходят из области видимости сразу же после удаления.

Оператор new

При запросе памяти из операционной системы в редких случаях она может быть не выделена (т.е. её может и не быть в наличии).

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

Во многих случаях процесс генерации исключения оператором new (как и сбой программы) нежелателен, поэтому есть альтернативная форма оператора new, которая возвращает нулевой указатель, если память не может быть выделена. Нужно просто добавить константу std::nothrow между ключевым словом new и типом данных:

В примере, приведенном выше, если оператор new не возвратит указатель с динамически выделенной памятью, то возвратится нулевой указатель.

Разыменовывать его также не рекомендуется, так как это приведет к неожиданным результатам (скорее всего, к сбою в программе). Поэтому наилучшей практикой является проверка всех запросов на выделение памяти для обеспечения того, что эти запросы будут выполнены успешно и память выделится:

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

Нулевые указатели и динамическое выделение памяти


Нулевые указатели (указатели со значением 0 или nullptr) особенно полезны в процессе динамического выделения памяти. Их наличие как бы сообщаем нам: «Этому указателю не выделено никакой памяти». А это, в свою очередь, можно использовать для выполнения условного выделения памяти:

Удаление нулевого указателя ни на что не влияет. Таким образом, в следующем нет необходимости:

Вместо этого вы можете просто написать:

Если ptr не является нулевым, то динамически выделенная переменная будет удалена. Если значением указателя является нуль, то ничего не произойдет.

Утечка памяти

Динамически выделенная память не имеет области видимости, т.е. она остается выделенной до тех пор, пока не будет явно освобождена или пока ваша программа не завершит свое выполнение (и операционная система очистит все буфера памяти самостоятельно). Однако указатели, используемые для хранения динамически выделенных адресов памяти, следуют правилам области видимости обычных переменных. Это несоответствие может вызвать интересное поведение, например:

Здесь мы динамически выделяем целочисленную переменную, но никогда не освобождаем память через использование оператора delete. Поскольку указатели следуют всем тем же правилам, что и обычные переменные, то, когда функция завершит свое выполнение, ptr выйдет из области видимости. Поскольку ptr — это единственная переменная, хранящая адрес динамически выделенной целочисленной переменной, то, когда ptr уничтожится, больше не останется указателей на динамически выделенную память. Это означает, что программа «потеряет» адрес динамически выделенной памяти. И в результате эту динамически выделенную целочисленную переменную нельзя будет удалить.

Это называется утечкой памяти. Утечка памяти происходит, когда ваша программа теряет адрес некоторой динамически выделенной части памяти (например, переменной или массива), прежде чем вернуть её обратно в операционную систему. Когда это происходит, то программа уже не может удалить эту динамически выделенную память, поскольку больше не знает, где выделенная память находится. Операционная система также не может использовать эту память, поскольку считается, что она по-прежнему используется вашей программой.

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

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

Это легко решается удалением указателя перед операцией переприсваивания:

Кроме того, утечка памяти также может произойти и через двойное выделение памяти:

Адрес, возвращаемый из второго выделения памяти, перезаписывает адрес из первого выделения. Следовательно, первое динамическое выделение становится утечкой памяти!

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

Заключение

С помощью операторов new и delete можно динамически выделять отдельные переменные в программе. Динамически выделенная память не имеет области видимости и остается выделенной до тех пор, пока не произойдет её освобождение или пока программа не завершит свое выполнение. Будьте осторожны, не разыменовывайте висячие или нулевые указатели.

На следующем уроке мы рассмотрим использование операторов new и delete для выделения и удаления динамически выделенных массивов.

Оценить статью:

Звёзд: 1Звёзд: 2Звёзд: 3Звёзд: 4Звёзд: 5 (568 оценок, среднее: 4,90 из 5)
Загрузка...

Комментариев: 34

  1. Павел:

    Если просто написать

    то произойдет утечка памяти?

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

    Или без присвоения её адреса указателю память не выделится?

    1. Макс:

      Да, если вы напишете new int; без присвоения возвращаемого указателя переменной, то динамически выделенная память будет выделена, но вы не будете иметь ссылки на нее, что приведет к утечке памяти.

      В этом случае нет переменной, которая бы хранила адрес выделенной памяти, поэтому вы не сможете освободить этот участок памяти с использованием delete. Утечка памяти произойдет, потому что вы потеряли ссылку на выделенную память, и не сможете освободить её позже.

  2. Георгий:

    Вопрос по поводу стека. Хочу привести пример для наглядного понимания что мне хотелось бы узнать. Допустим, есть ситуация в которой выполняется рекурсивная функция, но до конца она не выполняется и остаётся всего 1 вызов до его переполнения. Вместо того чтобы закончить свою рекурсию, он переходит к вызову другой функции.
    Вопрос, вторая рекурсивная функция будет складываться в тот же стек что и у первой? Для уточнения, меня интересует переполнится ли тогда от этого последнего вызова стек? Я же правильно понимаю, что стек один и общий для всех вызовов и не важно какая область видимости у него будет?

    Так же, бывает ещё ситуация, когда допустим программа состоит из нескольких файлов. А что если в одном из файлов стек почти будет переполнен? Второй файл сможет его переполнить?

  3. Илья:

    Не очень понимаю вопрос с повторным удалением. Да, удалить nullptr указатель мы можем безболезненно. Но это не нужно, ведь мы и так можем проверить, nullptr он или нет. А как определить, висячий указатель или нет? Ведь именно здесь может возникнуть вопрос, освобождать его память или нет? И повторное освобождение памяти — SIGABRT.

  4. Dima:

    Здравствуйте, скажите пожалуйста. Если я выделяю память динамически в функции main, пользуюсь этой памятью на протяжении всей работы программы и освобождение этой памяти собираюсь выполнить прямо перед закрывающей скобкой функции main, то получается я могу еë и не освобождать, раз когда программа завершается ОС еë всë равно освободит. Правильно ли это?

    1. nindemon:

      все верно, компилятор работает таким образом что после директив считывает стейтменты в функции main. если вы попробуете запустить программу без функции main то программа даст сбой с указанием отсутствия функции main, таким образом функция main задает общую структуру и последовательность вашему коду в программе.

  5. Роман:

    Правильно ли я понимаю, что если в нижеприведенной программе вместо delete ptr прописать delete otherPtr, то выполнится возвращение памяти в операционную систему? Если так, то по такой логике мы можем объявить динамическую переменную, далее несколько раз присвоить адрес этой переменной другим указателям, после чего удалить один из них, то память вернется обратно в ОС? Надеюсь, не слишком замудрил с вопросом….

    1. nindemon:

      касательно этого был подобный пример ранее, указатель OtherPtr имеет лишь доступ к динамической памяти где связующим звеном является указатель ptr. Лишь указатель ptr имеет прямой доступ на выделенную память

  6. Тарас:

    Здравствуйте.
    Не совсем понятно про "Нулевые указатели и динамическое выделение памяти".
    Правильно ли я понял, что если создать указатель,

    затем выделить ему память

    затем удалить

    То он снова будет нулевым, то есть nullptr.
    И если снова удалить освободить память на которую он указывает , то ничего не будет ?

    1. Евгений:

      Таким образом вы создадите висячий указатель, ИМХО это эквивалент

  7. Сергей:

    "Объем памяти стека в программе, как правило, невелик: в Visual Studio он по умолчанию равен 1МБ."
    Вопрос а как я могу управлять памятью стека. Или все прогрыммы должны укладваться в этот мегабайт?

    1. Владимир:

      Вопрос, а зачем вам управлять памятью стека? Насколько я понимаю это во первых управляется автоматически, а во вторых это тема уже низкоуровнего программирования (assembler там).
      Размер стека и их кол-во формируется автоматически, в зависимости от кол-ва обычных переменных, всевозможных функций в программе, статических и динамических библиотек (факторов много если коротко). Вам это может понадобится только если вы разрабатываете какие либо Операционные Системы или другие среды выполнения, ну или возможно программирование контроллеров.
      Я конечно и близко не специалист, если где то сказал откровенную чушь, поправте.

  8. Дмитрий:

    Добрый день. Пытаюсь сделать модуль С++ для питона. Модуль запускается однократно, во время его работы переполняется память. Модуль создает оператором new большое количество объектов. Sizeof(obj) = 1056. При создании около 60 000 объектов процесс занимает 2Г памяти и выдает прерывание bad_alloc.
    Количество выделяемой на объекты памяти я контролировал в функции, которая их порождает, сделал специальный счетчик для этого. Общая использованная память 1056 * 60 000 = 64М, т.е. в 30 раз меньше памяти процесса. В каком режиме запускать — с отладкой или без, не влияет.
    В чем проблема, откуда такой расход памяти?

    1. VectorASD:

      Конечно это моё предположение, но у питона как бы есть своя собственная трёхслойная система буферов/банков памяти, что позволяет нормально функционировать автосборщику мусора, функции type(), повторному использованию удалённых участков памяти автосборщиком и т.п. ;'-} Можете почитать в интернете, как работает выделение памяти под объекты в питоне самим питоном ;'-} Возможнотаки огромный расход памяти связан с тем, что на каждую переменную выделяется свой собственный банк памяти… Следовательно нужно думать, как затолкать все эти 60к переменных в один и тот же банк памяти. Других причин такого разноса скорее всего быть не может :SSS

  9. Хабибулло:

    Спасибо большое!
    Коротко и понятно)

  10. Виталий:

    Объясните, пожалуйста, целесообразность создания указателя для одной переменной.

    1. Евгений:

      Это может быть большая переменная, например, структура, которая будет содержать несколько переменных или большие массивы.

      1. Сергей:

        что значит правая часть, я имею ввиду скобки одинарные?

        1. Антон:

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

      2. Михаил:

        Евгений, здравствуйте!

        Вы можете подсказать как правильно выделить память для двухмерного массива вида std::array, завернутого в struct:

        Заранее благодарю.

  11. Игорь:

    Я так понял, что 99% всех переменных и массивов я буду использовать именно через динамическую память? И красивое и лаконичное int value(26); Превращается в нагромождение в пол экрана из следующего?

    И при этом ещё и следить за тем, чтобы освобождалась память при помощи delete?

    1. Евгений:

      Игорь, int это еще самая короткая запись для типа данных, который ты будешь использовать ))

  12. Oleg:

    Здравствуйте. Вопрос следующего характера. Мне необходимо написать несколько функций таким образом что бы всё взаимодействие было с одним динамическим массивом.

    — в первой функции создаем динамический массив.

    — во второй функции удаляем массив созданный первой функцией.

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

    1. Александр:

  13. Тимофей:

    Доброго времени суток!

    Вопрос у меня такой…

    Как, если память возвратилась обратно в ОС, переменную можно потом использовать?

    "Хотя может показаться, что мы удаляем переменную, но это не так! Переменная-указатель по-прежнему имеет ту же область видимости, что и раньше, ВОТ С ЭТОГО МЕСТА и ей можно присвоить новое значение, как и любой другой переменной."

    Буду рад услышать ваш ответ!

    1. Владимир:

      В вашем вопросе уже кроется половина ответа. Как и сказано в статье, delete возвращает в ОС память, на которую указывает указатель, и далее ОС может делать с ней всё, что пожелает. Но delete НЕ УДАЛЯЕТ переменную, то есть после выполнения этого стейтмента тому же самому указателю можно повторно выделить память в любое время. Для наглядности:

      Указатель после каждого оператора new будет указывать на один и тот же адрес памяти (не знаю, почему), однако содержимое этой области памяти будет разным. Если мы в 8 строке уберём { 20 }, то в консоль после адреса выведется рандомное значение.

      1. Евгений:

        > Указатель после каждого оператора new будет указывать на один и тот же адрес памяти
        Потому что вы освобождаете память и ОС еще не успевает ее отдать другому приложению. Вы сразу же занимаете эту область памяти снова.

  14. Максим:

    Автор! Спасибо за прекраснейщую статью! Как много полезной информации!

    1. Фото аватара Юрий:

      Спасибо, что читаешь 🙂

  15. Владимир:

    Здравствуйте) У меня тут небольшой вопросик общего характера по выделению памяти. Правильно ли я понимаю, что ,когда идёт компиляция программы, компилятор в каком-то из этапов проходится по программе и предоставляет для использования(но пока не заполняет) какие-то определённые адреса в памяти для переменных? А сама память выделяется, только тогда, когда я запускаю программу (в начале программы для глобальных переменных/массивов и в определенных блоках для локальных переменных/массивов). Так всё это работает??
    "Как статическое, так и автоматическое распределение памяти имеют две общие черты:
    Размер переменной/массива должен быть известен во время компиляции.
    Выделение и освобождение памяти происходит автоматически (когда переменная создается или уничтожается)."
    Ну со вторым пунктом всё понятно, а с первым не очень) То есть получается, (как я писал выше), под переменные или массивы память предоставляется ещё во время компиляции? Или тогда для чего нужно знать размер массива во время компиляции? Если да, то возникает ещё вопрос, если у меня в программе много функций в которых создаются переменные (именно создаются, а не передаются), компилятор проходит ещё и по всем функция, резервируя место и для этих переменных? в независимости, буду я вызывать эту функцию или нет? Заранее спасибо)))

    1. Фото аватара Юрий:

      Привет. Как уже было сказано в уроке — память выделяется во время компиляции автоматически, она не резервируется перед выполнением программы (до того как ты её запустишь), она сразу же выделяется, как компилятор встречает переменную. Компилятор перемещается построчно и если есть вызов функции, то он переходит в эту функцию и если встречает новую переменную, то выделяет сразу же ей память. Неважно, будешь ли ты использовать переменную в дальнейшем, если она есть в коде, то память для неё выделяется.

    2. Александр:

      тут скорее путанность в терминологии… человеческий язык не может отразить весь тот вынос мозга, который происходит в плюсах 🙂

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

      А фактическое распределение/выделение памяти происходит при фактическом вызове функции на этапе работы программы.

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

  16. Nanes:

    Когда до объектно-ориентированной части дойдем?

    1. Фото аватара Юрий:

      Этот материал будет немного позже.

Добавить комментарий

Ваш E-mail не будет опубликован. Обязательные поля помечены *