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

   ⁄ 

 Обновлено 7 Окт 2017

  ⁄   

⁄  2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Для динамического выделения памяти для одной переменной используется оператор 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 (4 оценок, среднее: 5,00 из 5)
Загрузка...
Поделиться в:
Подписаться на обновления:

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

  1. Nanes:

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

    1. Li4ik:

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

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

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