View Categories

Динамическая память

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

int a;  // переменная создана, память выделена здесь - глобально

void foo(int param) {   // здесь param создаётся
    // а при выходе из функции - удаляется
}

int main() {
    {
        int b;  // переменная создана, память выделена здесь
    }
    // а тут b сама удаляется из памяти

    foo(123);   // внутри создалась и удалилсь переменная-параметр
}

Области памяти #

Стек #

Таким образом, какой бы сложной ни была программа, автоматические переменные всегда расположены в памяти друг за другом - мы не можем создать ситуацию, в которой переменная удалилась бы из середины памяти. Можно представить такую память как стопку блинов: блины добавляются в стопку сверху, а мы можем взять и съесть только верхний блин. Из середины - ну никак не получится! Память, в которой живут автоматические переменные, так и называется - стопка, stack. Таким образом, локальные переменные создаются в стеке и всегда образуют красивую ровную стопку, которая автоматически сама себя контролирует.

Да кстати, размер блина в стопке мы тоже не можем изменить: какой положили - такой положили, уж простите. В данном случае размер блина - это тип переменной, её размер. Если мы создали массив на 10 элементов - он таким и останется, изменить его размер нельзя.

Куча #

А что делать, если очень хочется создать переменную, попользоваться ей какое-то время ВНЕ области определения, а затем вручную удалить? Или изменить размер массива? Здесь на помощь приходит второй тип памяти - динамическая. C/C++ позволяет управлять памятью в ручном режиме в специально отведённой для этого области - куче, heap. Здесь можно творить полнейший беспредел: самостоятельно создавать переменные, удалять их когда это нужно и даже менять размер выделенной памяти!

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

Случится страшное: программа "сломается" сразу, либо начнёт работать непредсказуемо.

Менеджер памяти #

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

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

Куча никогда не сможет врезаться в стек, а стек в кучу - запросто!

malloc/free #

Первая пара инструментов доступна нам из языка Си. Функция void* malloc(size_t size) принимает размер в количестве байт, выделяет в куче область памяти указанного размера и возвращает нам указатель на начало этой области:

  • Если это нулевой указатель nullptr - память выделить не удалось, использовать её нельзя
  • malloc просто выделяет память, она не инициализируется - содержит случайные значения
  • Указатель нужно сохранить - если "потерять" его, то выделенную память будет невозможно освободить

Для удаления данных, т.е. освобождения памяти, используется функция free(указатель) - ей передаётся указатель, который был получен от malloc():

  • Безопасно вызывать free() с нулевым указателем - free(nullptr)
  • Вызов free(указатель) дважды с одним и тем же значением указателя может привести к неопределённому поведению программы

Типичный пример использования:

  1. Выделяем количество байт, соответствующее нужному типу (можно измерить через sizeof для наглядности), результат преобразуем к указателю на этот тип и обязательно записываем в переменную-указатель, которая может быть автоматической (локальной, глобальной)
  2. Проверяем корректность (значение) указателя через if, т.е. получилось ли выделить память. Если память не выделилась - освобождать её будет не нужно!
  3. Когда память становится не нужна - освобождаем её
int* iptr = (int*)malloc(sizeof(int));      // #1

if (iptr) {         // #2
    *iptr = 1234;   // запись
    int i = *iptr;  // чтение

    free(iptr);     // удаление - #3
}

Нет смысла динамически выделять переменные стандартных типов, точнее типов легче или равных size_t - придётся ведь хранить их адрес в указателе, а он сам весит как size_t

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

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

// массив int[10]
int* arr = (int*)malloc(sizeof(int) * 10);
arr[0] = 123;
arr[1] = 456;

// структура
struct Foo {
    int a, b, c;
};

Foo* foo = (Foo*)malloc(sizeof(Foo));
foo->a = 123;

free(foo);
free(arr);

Есть ещё более удобная функция для массивов calloc(n, size) - выделяет n ячеек указанного размера size и инициализирует их нулями:

// аналогично массив int[10], инициализированный
int* arr = (int*)calloc(10, sizeof(int));
free(arr);

// массив структур Foo[3], инициализированный
Foo* foo_arr = (Foo*)calloc(3, sizeof(Foo));
foo_arr[0].a = 123;
foo_arr[1].b = 456;

free(foo_arr);

new/delete #

В объектно-ориентированном C++ появился новый инструмент для выделения памяти - оператор new - new тип_данных(). Главное его отличие от malloc() состоит в том, что он ориентирован на объекты - после выделения памяти он также проводит инициализацию, а именно - вызывает конструктор. Для освобождения памяти, выделенной через new, используется оператор delete - delete указатель - он вызывает уже деструктор объекта.

  • new тип без скобок вызывает конструктор по умолчанию
  • new тип(..) вызывает указанный конструктор

В C++ базовые типы данных тоже имеют "конструктор" для совместимости, то есть запись int i = int() является корректной и инициализирует нулём, а int(5) - числом 5

Конструктор по умолчанию для базовых типов не проводит инициализацию! new int просто выделит память, а new int() или new int(0) выделит и инициализирует нулём

Для корректного динамического создания экземпляров классов нужно использовать именно new, а не malloc - malloc просто выделяет память

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

// базовые типы
int* i1 = new int;          // просто выделить память
int* i2 = new int(123);     // выделить и инициализировать числом 123

if (!i2) {
    // обработка ошибки выделения памяти
}

delete i2;
delete i1;

// класс
class Foo {
  public:
    Foo() {}        // #1
    Foo(int i) {}   // #2
};

Foo* foo1 = new Foo;        // вызовет #1 (конструктор по умолчанию)
Foo* foo2 = new Foo();      // вызовет #1
Foo* foo3 = new Foo(123);   // вызовет #2

delete foo3;
delete foo2;
delete foo1;

Для создания массивов используется синтаксис new тип_данных[количество] и delete [] указатель соответственно. Для создания многомерных массивов точно так же обязательно указываются константами младшие размерности (как при статическом создании), старшие могут быть динамическими и получены во время работы программы:

int* arr = new int[10];     // выделен массив int[10]
delete [] arr;

int num = 5;
float (*arr2)[10] = new float[num][10];     // двумерный массив float
//auto arr2 = new float[num][10];           // или так, для удобства
delete [] arr2;

//float(*arr3)[num] = new float[num][num];  // ошибка - неконстантная младшая размерность

int** arrp = new int*[10]();    // массив указателей на int (аналог int* arrp[])
delete [] arrp;

При создании массива тоже можно вызвать конструктор - он применится ко всем элементам массива:

int* arr = new int[10]();   // массив int[10] инициализирован нулями
delete [] arr;

arr = new int[10](123); // массив int[10] инициализирован 123
delete [] arr;
class Foo() {
  public:
    Foo(int a, int b) {}
};

Foo* arrf = new Foo[5](123, 456);
delete arrf;

"Дырки" в памяти #

Вот теперь можно легко представить ситуацию, при которой в памяти образуется "дырка" - нарушение стопки:

char* a = new char();
char* b = new char();
// [a][b]

delete a;
// [-][b]   // "дырка" 1 байт

При следующем выделении менеджер памяти будет проверять такие дырки:

  • Если в очередной дырке достаточно места - он выделит память в ней
  • Если недостаточно - перейдёт к следующей, и так до конца кучи

Продолжение примера:

long* c = new long();   // 4 байта
// [-][b][cccc] <- не хватило места под long, пошли дальше

char* d = new char();
// [d][b][cccc]
// а вот d уместилась в 1-байт дырку

Чуть другая ситуация:

long* a = new long();
long* b = new long();
delete a;
// [----][bbbb]

char* c = new char();
// [c][---][bbbb]

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

  • Куча будет занимать больше памяти, чем переменные в ней (с учётом дырок)
  • Выделение памяти будет происходить медленнее

realloc #

В Си есть ещё одна очень полезная функция для работы с памятью - realloc(), она позволяет изменить размер выделенной области памяти: void* realloc(void* ptr, size_t size), где ptr - указатель на старую область (может быть nullptr), а size - новый размер. Это работает следующим образом:

  • Если новый размер меньше старого - ничего не произойдёт, будет возвращён старый адрес
  • Если сразу за текущей областью есть свободное место (смежное), в которое можно расшириться в "новый" размер - будет выделена дополнительная память и возвращён старый адрес
  • Если места на расширение нет - выделится новая область указанного размера, данные автоматически скопируются в неё и будет возвращён новый адрес
  • Если места под новый размер не окажется - будет возвращён нулевой указатель

С учётом этих особенностей, использование realloc() обычно выглядит так:

T* ptr;     // указатель на данные

// делаем realloc на новый размер, запоминаем во временный указатель
T* nptr = (T*)realloc(ptr, new_size);
if (nptr) {         // если успешно
    ptr = nptr;     // меняем свой указатель
}
int* ptr = nullptr;

// функция меняет размер области и возвращает true при успехе
bool resize(size_t nsize) {
    int* nptr = (int*)realloc(ptr, nsize);
    if (nptr) {
        ptr = nptr;
        return true;
    }
    return false;
}

resize(10);
resize(20);
resize(30);
resize(999999999);  // на МК это точно будет false =)
// Пусть a - это область в 1 байт, а b - какие то данные в памяти. Давайте реаллочить [a]
// [a][------][bbbb]            +5
// [aaaaaa][-][bbbb]            +2
// [-------][bbbb][aaaaaaaa]    +5
// [-------][bbbb][aaaaaaaaaaaaa]

В C++ есть аналог realloc для объектов - контейнер std::vector. В рамках данного урока мы его не рассматриваем

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

Мы работаем с памятью полностью в ручном режиме и берём на себя всю ответственность - процессор не поможет, если мы вдруг "потеряем" указатель на выделенную память и не сможем её освободить. Это называется утечкой памяти (memory leak):

int* p1;

{
    p1 = new int();
    p1 = nullptr;       // утечка! Указатель потерян
}

{
    int* p2 = new int();
} // утечка! Локальная переменная удалилась

new int();          // утечка! Даже не сохранили указатель

while (new int());  // выжигаем свободную память =)

Время жизни указателя #

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

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

// глобальный указатель
int* ptr = nullptr;

// создать что-то
void init() {
    ptr = new int();    // утечка памяти
}

// удалить
void reset() {
    delete ptr;         // неопределённое поведение
}

Подразумевается, что init() и reset() вызываются попарно и только по одному разу - за init() всегда следует reset(). Это - уязвимость, потому что если программист вызовет init() несколько раз подряд без вызовов reset() - память "утечёт".

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

  • Сбрасывать указатель в nullptr после удаления данных
  • Проверять указатель перед выделением памяти и удалять ИЛИ не выделять её заново
// глобальный указатель
int* ptr = nullptr;

// создать что-то
void init() {
    if (ptr) reset();   // вариант 1: если данные уже существуют - удалить их
    //if (ptr) return;  // вариант 2: если данные уже существуют - ничего не делать

    ptr = new int();    // теперь безопасно
}

// удалить
void reset() {
    delete ptr;         // теперь безопасно
    ptr = nullptr;      // помечаем, что данные удалены
}

"Умные" указатели #

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

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх