До этого мы рассматривали только статическую память и автоматические переменные - память под переменную выделяется в том месте, где переменная была определена. При выходе из своей области видимости переменная сама удаляется из памяти:
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
Освобождать память рекомендуется в обратном порядке - как это делает стек, чтобы не создавать "дыр" в памяти. В реальной крупной программе может не получиться так делать, но там где можно - лучше придерживаться этого принципа
Динамическое выделение часто используется для создания временных массивов и структур:
// массив 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++ не рекомендуется использовать память вот так, в явном виде через обычные указатели, нужно применять "умные указатели" - они безопаснее. Рассмотрим их позже в отдельном уроке.