До этого мы рассматривали только статическую память и автоматические переменные - память под переменную выделяется в том месте, где переменная была определена. При выходе из своей области видимости переменная сама удаляется из памяти:
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(указатель)дважды с одним и тем же ненулевым указателем может привести к неопределённому поведению программы
Типичный пример использования:
- Выделяем количество байт, соответствующее нужному типу (можно измерить через sizeofдля наглядности), результат преобразуем к указателю на этот тип и обязательно записываем в переменную-указатель, которая может быть автоматической (локальной, глобальной)
- Проверяем корректность (значение) указателя через if, т.е. получилось ли выделить память. Если память не выделилась - освобождать её будет не нужно!
- Когда память становится не нужна - освобождаем её
int* iptr = (int*)malloc(sizeof(int));      // #1
if (iptr) {         // #2
    *iptr = 1234;   // запись
    int i = *iptr;  // чтение
    free(iptr);     // удаление - #3
}Нет смысла динамически выделять переменные стандартных типов, точнее типов легче или равных size_t - придётся ведь хранить их адрес в указателе, а он сам весит как size_t
malloc(0) выделит 0 байт и вернёт ненулевой указатель - эту память точно так же нужно будет освободить
Освобождать память рекомендуется в обратном порядке - как это делает стек, чтобы не создавать "дыр" в памяти. В реальной крупной программе может не получиться так делать, но там где можно - лучше придерживаться этого принципа
Динамическое выделение часто используется для создания временных массивов и структур:
// массив 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* arr1 = new int[10]();  // выделен массив int[10], инициализирован 0
delete [] arr1;
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;Выделение массива нулевой длины (new int[0]) возвращает ненулевой указатель - эту память тоже нужно будет освободить через delete
При создании массива объектов конструктор вызвать нельзя:
class Foo {
  public:
    Foo() {}                // #1
    Foo(int a, int b) {}    // #2
};
Foo* arr1 = new Foo[5](123, 456);   // ошибка, #2
Foo* arr2 = new Foo[5]();           // не ошибка, #1
Foo* arr2 = new Foo[5];             // не ошибка, #1 
"Дырки" в памяти #
Вот теперь можно легко представить ситуацию, при которой в памяти образуется "дырка" - нарушение стопки:
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() - память "утечёт". А если вызвать reset() несколько раз подряд - программа скорее всего сломается, так как будет освобождена уже освобождённая память по этому адресу.
Указатель, который указывает на уже удалённые данные, называют висячим. Нужно избегать такого поведения в своей программе
Решается проблема очень просто: использовать значение самого указателя как индикатор наличия данных, при освобождении - присваивать nullpt. Такой подход будет работать для всех ситуаций с глобальным указателем для динамических данных:
- После удаления данных сбрасывать указатель в nullptr
- Перед выделением памяти проверять указатель. Если он ненулевой:
- Освободить память
- Не выделять память
 
// глобальный указатель
int* ptr = nullptr;
// создать что-то
void init() {
    if (ptr) reset();   // вариант 1: если данные уже существуют - удалить их
    //if (ptr) return;  // вариант 2: если данные уже существуют - ничего не делать
    ptr = new int();    // теперь безопасно
}
// удалить
void reset() {
    delete ptr;         // теперь безопасно
    ptr = nullptr;      // помечаем, что данные удалены
}"Умные" указатели #
Как вы могли понять из урока, работа с динамической памятью - довольно опасная и ответственная штука. В C++ не рекомендуется использовать память вот так, в явном виде через обычные указатели, нужно применять "умные указатели" - они безопаснее. Рассмотрим их позже в отдельном уроке.
Дополнительно #
Дополнительный контент доступен владельцам набора GyverKIT и по подписке, подробнее читай здесь. Блок содержит:
- Тезисы, Задания
- 2 блоков кода
Полезные страницы #
- Набор GyverKIT – наш большой стартовый набор Arduino, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])
- Поддержать автора за работу над уроками
