10.13 – Динамическое распределение памяти с помощью new и delete

Добавлено 8 июня 2021 в 20:09
Глава 10 – Массивы, строки, указатели и ссылки  (содержание)

Необходимость динамического распределения памяти

C++ поддерживает три основных типа распределения памяти, два из которых вы уже видели.

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

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

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

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

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

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

char name[25];           // будем надеяться, что имя меньше 25 символов!
Record record[500];      // будем надеяться, что записей меньше 500!
Monster monster[40];     // максимум 40 монстров
Polygon rendering[30000];// в этом 3D-рендере должно быть не больше 30 000 полигонов!

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

Во-первых, это приводит к потере памяти, если переменные на самом деле не используются. Например, если мы выделяем 25 символов для каждого имени, но в среднем имена имеют длину всего 12 символов, мы используем вдвое больше, чем нам действительно нужно. Или рассмотрите приведенный выше массив рендеринга: если рендеринг использует только 10 000 полигонов, у нас 20 000 полигонов памяти не используются!

Во-вторых, как узнать, какие биты памяти действительно используются? Для строк это просто: строка, начинающаяся с \0, явно не используется. А как насчет monster[24]? Он сейчас жив или мертв? Это требует какого-то способа отличать активные элементы от неактивных, что увеличивает сложность и может использовать дополнительную память.

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

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

int main()
{
    int array[1000000]; // распределяем 1 миллион чисел int (вероятно, 4 МБ памяти)
}

Ограничение всего 1 МБ памяти было бы проблематичным для многих программ, особенно тех, которые имеют дело с графикой.

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

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

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

Чтобы динамически распределить одну переменную, мы используем простую (не для массивов) форму оператора new:

new int; // динамически распределяем целочисленную переменную (и отбрасываем результат)

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

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

int* ptr{ new int }; // динамически распределяем целочисленную переменную и
                     // присваиваем адрес ptr, чтобы было можно получить
                     // к ней доступ позже

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

*ptr = 7; // присвоить значение 7 выделенной памяти

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

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

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

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

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

Инициализация динамически выделяемой переменной

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

int* ptr1{ new int (5) };   // использовать прямую инициализацию
int* ptr2{ new int { 6 } }; // использовать унифицированную инициализацию

Удаление одиночных переменных

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

// предполагаем, что ptr ранее был распределен с оператором new
delete ptr; // возвращаем операционной системе память, на которую указывает ptr 
ptr = 0;    // устанавливаем ptr как нулевой указатель (в C++11 вместо 0 используйте nullptr)

Что значит «удалить» память?

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

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

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

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

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

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

#include <iostream>
 
int main()
{
    int* ptr{ new int }; // динамически распределяем переменную типа int
    *ptr = 7;            // помещаем значение в эту ячейку памяти
 
    delete ptr; // возвращаем память операционной системе.
                // ptr теперь является висячим указателем
 
    std::cout << *ptr; // косвенное обращение через висячий указатель
                       // вызовет неопределенное поведение
    delete ptr; // попытка снова освободить память также приведет к неопределенному поведению
 
    return 0;
}

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

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

#include <iostream>
 
int main()
{
    int* ptr{ new int{} }; // динамически распределяем int
    int* otherPtr{ ptr };  // otherPtr теперь указывает на то же место в памяти
 
    delete ptr; // возвращаем память операционной системе.
                // ptr и otherPtr теперь являются висячими указателями.
    ptr = nullptr; // ptr теперь  nullptr
 
    // однако otherPtr всё еще остается висячим указателем!
 
    return 0;
}

Здесь могут помочь несколько передовых практик.

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

Во-вторых, при удалении указателя, если этот указатель не выходит за пределы области сразу после этого, установите указатель на 0 (или nullptr в C++11). Мы поговорим подробнее о нулевых указателях и о том, почему они полезны, чуть позже.

Лучшая практика


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

Оператор new может завершиться со сбоем

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

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

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

int* value = new (std::nothrow) int; // value будет установлено в нулевой указатель,
                                     // если выделение int не удалось

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

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

int* value{ new (std::nothrow) int{} }; // запрашиваем память для int
if (!value) //обрабатываем случай, когда new вернул null
{
    // здесь обрабатываем ошибки
    std::cout << "Could not allocate memory";
}

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

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

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

// Если ptr еще не распределен, распределяем его
if (!ptr)
    ptr = new int;

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

if (ptr)
    delete ptr;

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

delete ptr;

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

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

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

Рассмотрим следующую функцию:

void doSomething()
{
    int* ptr{ new int{} };
}

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

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

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

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

int value = 5;
int* ptr{ new int{} }; // выделяем память
ptr = &value;          // старый адрес потерян, результат - утечка памяти

Это можно исправить, удалив указатель перед его переприсваиванием:

int value{ 5 };
int* ptr{ new int{} }; // выделяем память
delete ptr;            // возвращаем память обратно операционной системе
ptr = &value;          // переназначить указатель на адрес value

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

int* ptr{ new int{} };
ptr = new int{}; // старый адрес потерян, результат - утечка памяти

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

Этого точно так же можно избежать, удалив указатель перед переназначением.

Заключение

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

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

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

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

Теги

bad_allocC++ / CppLearnCppnew / deletestd::nothrowВыделение памятиДинамическое распределение памятиДля начинающихНулевой указательОбучениеПрограммированиеУтечка памяти

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.