# Глава 15.4. Динамическое выделение памяти
Временем жизни переменных в динамической памяти управляет разработчик. Оно _не заканчивается_ при выходе из области видимости. Поэтому все конструкции для управления памятью _парные:_ после выделения памяти её нужно освободить.
Выделение динамической памяти называется **аллокацией** (allocation). На одну аллокацию должно приходиться ровно одно освобождение.
Если вы забудете освобождение, то получите **утечку памяти** (memory leak). Занятая область будет возвращена ОС только при завершении программы.
Если вы освободите одну и ту же область дважды, то получите **двойное освобождение памяти** (double free). Это повреждение памяти, приводящее к любым последствиям. Иными словами, крайне опасный вид UB. Казалось бы, в чём проблема? Подумаешь, попытка освободить блок памяти дважды. Ответ — в конце главы.
Если вы обратитесь к уже освобождённой памяти (use-after-free), то получите UB, который может привести к порче данных и уязвимости. Эта ошибка часто эксплуатируется для выполнения произвольного кода и кражи данных.
В C++ есть несколько конструкций для работы с памятью. Перечислим их от низкоуровневых к высокоуровневым. Чем способ более высокоуровневый — тем он более предпочтительный. Наиболее высокоуровневый способ — это умные указатели, но их мы рассмотрим в следующих главах.
## Низкий уровень в Си: malloc() и free()
Когда в 80-х годах C++ только начинал формироваться и назывался «Си с классами», важным для его распространения было:
- Поддерживать полную совместимость с Си.
- Обеспечивать безболезненный переезд проектов с Си на «Си с классами».
За 40 лет мир изменился, и C++ давно [перестал быть](/courses/cpp/chapters/cpp_chapter_0012/#block-c-cpp) надстройкой над Си. Однако с тех времён осталось наследие: бесшовный вызов из C++ функций библиотеки [рантайма Си.](/courses/cpp/chapters/cpp_chapter_0112/#block-runtime) По умолчанию любая программа на C++ [линкуется с рантаймом Си.](/courses/cpp/chapters/cpp_chapter_0112/#block-c-runtime) Для этого всего лишь нужно подключить сишный хедер:
```cpp
#include <stdlib.h>
```
В нём объявлены функции [malloc()](https://en.cppreference.com/w/cpp/memory/c/malloc.html) (memory allocation) и [free()](https://en.cppreference.com/w/cpp/memory/c/free.html). Это основные, но не единственные функции для управления памятью.
В `malloc()` передаётся количество байт для аллокации. Функция выделяет память и возвращает на неё указатель типа `void *`:
```cpp
void * malloc(size_t size);
```
Что означает `void *`? Это **универсальный указатель:** он ссылается на область памяти с _любыми_ данными. Его тип данных не известен компилятору, и у вас не получится:
- Разыменовать указатель оператором `*` для доступа к данным.
- Применить к нему адресную арифметику.
Поэтому перед доступом к объекту указатель `void *` приводится к нужному типу.
Если `malloc()` не удаётся выделить память, функция возвращает `NULL` (он же `nullptr`).
Функция `free()` освобождает память по указателю:
```cpp
void free(void * ptr);
```
Так выглядит вызов этих функций для аллокации и освобождение памяти под 5 объектов типа `int`:
```cpp {.example_for_playground}
#include <stdlib.h>
import std;
int main()
{
const std::size_t n = 5;
const std::size_t bytes = n * sizeof(int);
// Выделяем память
int * arr = static_cast<int *>(malloc(bytes));
// Проверка, чтобы при обращении к памяти не получить UB
if (arr == nullptr)
{
std::println("Couldn't allocate {} bytes for array", bytes);
return 1;
}
for (int i = 0; i < n; ++i)
{
arr[i] = i * i;
std::println("{}-th element. Value: {}", i, arr[i]);
}
// Освобождаем память
free(arr);
}
```
```
0-th element. Value: 0
1-th element. Value: 1
2-th element. Value: 4
3-th element. Value: 9
4-th element. Value: 16
```
Вызов `malloc()` вернул указатель `void *`, и нам пришлось привести его к `int *` через `static_cast`. Когда память стала не нужна, мы освободили её через `free()`. В некоторых случаях имеет смысл после вызова `free()` обнулять указатель:
```cpp
arr = nullptr;
```
Это позволяет избежать двойного освобождения памяти, если указатель используется дальше по коду.
### std::malloc() и std::free()
[Пространства имён,](/courses/cpp/chapters/cpp_chapter_0053/) появившиеся ещё на заре C++, позволяют предотвращать конфликты имён и удобно группировать код. Не удивительно, что функции библиотеки рантайма Си были добавлены в пространство имён `std`.
Для использования функций управления памятью из пространства `std` необходимо подключить заголовок `cstdlib` или модуль `std`:
```cpp {.example_for_playground .example_for_playground_003}
#include <cstdlib> // Вместо stdlib.h
int main()
{
const std::size_t n = 5;
const std::size_t bytes = n * sizeof(int);
// Обращаемся к malloc и free из пространства имён std
int * arr = static_cast<int *>(std::malloc(bytes));
// ...
std::free(arr);
}
```
Разницы между `malloc()` / `free()` и `std::malloc()` / `std::free()` нет. Просто второй вариант подчёркивает, что перед вами код на C++. Но оба варианта плохи. Их не рекомендуется использовать в современном C++ без явной необходимости. Вот основные причины:
- Эти функции нужны скорее для совместимости, чем для написания кода в новых проектах.
- Единственное, что делает `malloc()` — выделяет сырую память, в которой может находиться что угодно. Вы обязаны инициализировать её вручную:
- Для простых типов требуется инициализация значением.
- Для других — ручной вызов конструктора.
- `malloc()` возвращает указатель `void *`, и вам нужно самостоятельно приводить его к указателю на нужный тип.
Прочитайте функцию `is_valid_pass()`. Считайте, что вспомогательные функции `normalize()`, `is_valid()` и `is_strong()` уже реализованы в проекте и не кидают исключений. {.task_text}
Правильно ли организовано управление памятью в этой функции? Введите: `x`, если ошибок управления памятью нет; `l`, если есть утечка памяти; `f`, если память освобождается дважды. {.task_text}
```cpp {.example_for_playground .example_for_playground_001}
// Проверяет, что пароль состоит из корректных символов
// Пароль не бывает равен nullptr
bool is_valid_pass(const char * pass)
{
// +1 нужен для учёта завершающего нуля '\0'
const std::size_t bytes = std::strlen(pass) + 1;
// Выделяем память для копии строки
char * pass_normalized = static_cast<char *>(std::malloc(bytes));
// Не забываем проверить, выделилась ли память
if (pass_normalized == nullptr)
throw std::bad_alloc{};
char * dst = pass_normalized;
dst[bytes - 1] = '\0'; // Зануляем последний символ
const char * end = pass + bytes - 1;
for (const char *src = pass; src != end; ++src)
{
*dst = normalize(*src);
if (!is_valid(*dst))
{
std::println("Password contains invalid symbol");
return false;
}
++dst;
}
if (!is_strong(pass_normalized))
{
std::println("Password is not strong enough");
return false;
}
std::free(pass_normalized);
return true;
}
```
```consoleoutput {.task_source #cpp_chapter_0154_task_0010}
```
В функции есть три точки выхода. Но только в последней освобождаются ресурсы. {.task_hint}
```cpp {.task_answer}
l
```
## Модуль std.compat
Представьте ситуацию: огромный легаси-проект на C++ нужно перевести с хедеров на модули. Но в нём очень много вызовов сишных функций без префикса `std::`. А модуль `std` не экспортирует сущности в глобальное пространство имён. Значит, при переезде на модули проект перестанет компилироваться. Как быть?
Специально для этого сценария в C++23 был добавлен модуль `std.compat`. Он экспортирует всё то же самое, что и `std`, и делает функции и типы библиотеки Си доступными в глобальном пространстве имён:
```cpp
import std.compat; // теперь malloc() и free() доступны без std::
int main()
{
// ...
int * arr = static_cast<int *>(malloc(bytes));
// ...
free(arr);
}
```
В нашей песочнице нет модуля `std.compat`, поэтому у нас этот пример не скомпилируется. Но вы можете добиться его сборки у себя локально.
## На уровень выше: выражения new и delete
Выражения [new](https://en.cppreference.com/w/cpp/language/new.html) и [delete](https://en.cppreference.com/w/cpp/language/delete.html) нужны, чтобы управлять памятью конкретного типа `T`. Позже вы узнаете, что в этих выражениях участвуют одноимённые операторы, которые можно перегружать.
Выражение `new` выделяет память под конкретный тип, а `delete` освобождает её:
```cpp
T * p = new T;
// Работаем с p
delete p;
```
Например:
```cpp {.example_for_playground .example_for_playground_004}
int * x = new int{6000};
*x += 2;
std::println("{}", *x);
delete x;
```
```
6002
```
При выполнении `int * x = new int{6000}` происходит следующее:
1. На куче выделяется память под тип `int`. Например, 4 байта.
2. Она инициализируется целочисленным значением `6000`.
3. Выражение `new` возвращает указатель на эту память.
4. Указатель сохраняется в переменную `x`.
При выполнении `delete x` выделенная память помечается свободной, а указатель `x` становится висячим (dangling pointer): он перестаёт указывать на корректную область памяти. В любой момент по этому адресу может быть создана другая переменная.
Если у типа есть конструктор, то он срабатывает при вызове `new` сразу после выделения памяти. При вызове `delete` сначала срабатывает деструктор, а потом освобождается память.
Создадим класс `SemVer` для [семантического версионирования,](https://semver.org/lang/ru/#:~:text=%D0%A1%D0%B5%D0%BC%D0%B0%D0%BD%D1%82%D0%B8%D1%87%D0%B5%D1%81%D0%BA%D0%BE%D0%B5%20%D0%92%D0%B5%D1%80%D1%81%D0%B8%D0%BE%D0%BD%D0%B8%D1%80%D0%BE%D0%B2%D0%B0%D0%BD%D0%B8%D0%B5%202.0.0%20%7C%20Semantic%20Versioning.) чтобы посмотреть в консоли стадии жизни объекта: {#block-semver}
```cpp {.example_for_playground .example_for_playground_005}
class SemVer
{
public:
SemVer()
{
std::println("Default constructor");
}
SemVer(std::int32_t major, std::int32_t minor, std::int32_t patch)
: m_major(major), m_minor(minor), m_patch(patch)
{
std::println("Parameterized constructor: {}.{}.{}",
m_major, m_minor, m_patch);
}
~SemVer()
{
std::println("Destructor: {}.{}.{}", m_major, m_minor, m_patch);
}
void print()
{
std::println("Version: {}.{}.{}", m_major, m_minor, m_patch);
}
private:
std::int32_t m_major = 0;
std::int32_t m_minor = 0;
std::int32_t m_patch = 0;
};
```
Заведём объект `SemVer` в динамической памяти, вызовем его метод, а затем уничтожим:
```cpp {.example_for_playground .example_for_playground_006}
int main()
{
SemVer * ver = new SemVer{1, 0, 134};
ver->print();
delete ver;
std::println("Exiting main");
}
```
```
Parameterized constructor: 1.0.134
Version: 1.0.134
Destructor: 1.0.134
Exiting main
```
Обратите внимание, что разрушение объекта произошло _до_ вывода строки `"Exiting main"`.
При выполнении `SemVer * ver = new SemVer{1, 0, 134}` происходит следующее:
1. На куче выделяется область памяти под тип `SemVer`.
2. Параметризированный конструктор `SemVer(std::int32_t, std::int32_t, std::int32_t)` инициализирует выделенную память.
3. Выражение `new` возвращает указатель на эту область памяти.
4. Указатель сохраняется в переменную `ver`.
При удалении `delete ver` производятся обратные действия:
1. Деструктор освобождает использованные ресурсы. В случае объекта класса `SemVer` ничего кроме вывода в консоль не происходит, так как для переменных целого типа не требуется специального освобождения ресурсов.
2. Выражение `delete` освобождает использованную область памяти.
Возьмём класс `SemVer` из примера выше. Вызовется ли его деструктор, если закомментировать `delete ver`? `Y/N`. {.task_text}
```cpp {.example_for_playground .example_for_playground_007}
int main()
{
SemVer * ver = new SemVer{1, 0, 134};
ver->print();
// delete ver;
}
```
```consoleoutput {.task_source #cpp_chapter_0154_task_0020}
```
За вызов деструктора объекта по указателю отвечает `delete`. {.task_hint}
```cpp {.task_answer}
N
```
Важно понимать, что после `delete ver` переменная `ver` указывает на _освобождённую_ область памяти. Попытка использовать `ver` как объект `SemVer` приведёт к UB. В лучшем случае программа аварийно завершится. Если возможно дальнейшее использование указателя, то его нужно занулить:
```cpp
delete ver;
ver = nullptr;
```
Кстати, то же самое можно написать изящнее:
```cpp
delete std::exchange(ver, nullptr);
```
Вызов [std::exchange()](https://en.cppreference.com/w/cpp/utility/exchange.html) заменяет значение `ver` на `nullptr` и возвращает _старое_ значение `ver`. За счёт этого выход `std::exchange()` подаётся на вход `delete`, и область памяти корректно освобождается.
Есть ли в этом примере кода ошибки управления памятью? Введите: `x`, если ошибок управления памятью нет; `l`, если есть утечка памяти; `f`, если память освобождается дважды. {.task_text}
```cpp {.example_for_playground .example_for_playground_008}
void process(int * data)
{
std::println("Processing data {}...", *data);
delete data;
}
void process_and_release()
{
int * value = new int(164);
process(value);
// ...Спустя много строк кода
if (value)
{
delete value;
value = nullptr;
}
}
```
```consoleoutput {.task_source #cpp_chapter_0154_task_0030}
```
Нарушен полезный принцип «кто выделил память, тот и удаляет». {.task_hint}
```cpp {.task_answer}
f
```
## Сравнение malloc() и new T
Подытожим различия между сишными функциями `malloc()`/`free()` и выражениями C++ `new T`/`delete`.
- `malloc()` выделяет сырые байты, заполненные мусором. А `new T` выделяет память и инициализирует её объектом.
- `malloc()` возвращает универсальный указатель `void *`, который нужно привести к требуемому типу. А `new T` возвращает указатель на объект `T *`.
- Если `malloc()` не может выделить память, то возвращает `nullptr`. А `new T` кидает исключение `std::bad_alloc`.
- Чтобы освободить выделенную `malloc()` память, нужно вызвать `free()`. Чтобы освободить память после `new T`, нужно вызвать `delete`. **Не смешивайте** эти два способа! Нельзя освободить выделенную `new T` память через `free()` и наоборот.
- Под капотом `new T` вызывает `malloc()`.
Знание об `malloc()` необходимо для представления, как вообще выглядит управление памятью. Однако не используйте `malloc()` в своём коде! Ну разве что для совместимости с кодом на Си или для создания собственных аллокаторов (менеджеров памяти). Да и `new T` в современном C++ — тоже плохая практика. Вместо него рекомендуются умные указатели — RAII-классы с автоматическим освобождением ресурсов, о которых вы скоро узнаете.
## Почему нельзя смешивать malloc()/delete или new T/free()
Почему для указателя, полученного через `malloc()`, нельзя вызвать `delete`? И наоборот, почему `new Т` нельзя смешивать с `free()`?
Если вызвать `new T`, а потом `free()`, то не произойдет вызова деструктора, и какие-то ресурсы не будут освобождены до самого завершения программы.
А что насчёт [фундаментальных типов,](/courses/cpp/chapters/cpp_chapter_0131/) без конструтора и деструктора?
Во-первых, в Стандарте не указано, как должны быть реализованы `new` и `delete`. Разработчики стандартной библиотеки вольны использовать любые функции для аллокации и освобождения. Да, сейчас популярные реализации стандартной библиотеки С++ вызывают `malloc()`/`free()` под капотом `new T`/`delete`. Но это не гарантирует, что так во _всех_ реализациях или что в будущем ситуация не изменится.
Во-вторых, участвующие в одноимённых выражениях операторы `new` и `delete` можно перегружать. Например, чтобы они использовали заранее аллоцированную область памяти или работали напрямую с функциями ОС для управления кучей. Тогда смешивание перегруженных `new`/`delete` с `malloc()`/`free()` в лучшем случае приведёт к аварийному завершению программы. А в худшем — к трудно уловимым багам.
## Менеджер памяти рантайма Си
Выражение `new T` — удобная C++ абстракция над вызовом сишной функции `malloc()`, которая живёт в библиотеке рантайма Си. Под Linux она называется [glibc](https://www.gnu.org/software/libc/) (GNU C Library), а под Windows — [CRT](https://learn.microsoft.com/en-us/cpp/c-runtime-library/windows-platforms-crt) (Microsoft C Runtime Library).
В библиотеке рантайма Си реализован менеджер памяти — прослойка между программой и ОС. Его цель — сделать работу с памятью быстрой и экономной, а именно:
- Минимизировать системные вызовы. Это важно для производительности, ведь системный вызов — дорого.
- Бороться с фрагментацией памяти. Если без хитрой стратегии просто выделять и освобождать блоки памяти, то куча превратится в сыр с мелкими дырками. В нём не получится выделить единый блок под массив, даже если суммарно памяти достаточно.
- Поддерживать многопоточность. Если за выделение памяти конкурируют несколько потоков, нельзя допускать длинной очереди.
- Следить за целостностью данных.
Менеджер памяти в glibc, CRT и других реализациях выполняет схожие задачи, но спроектирован по-разному. Чтобы у вас сложилось хотя бы примерное представление об одном из вариантов его устройства, давайте возьмём за пример glibc.
Он оперирует **чанками** (chunks, куски). Это базовые кирпичики памяти, которые аллоцируются при вызове `malloc()`. Но `malloc()` выделяет чуть больше памяти, чем запрошено: каждый чанк содержит не только пользовательскую память, но и метаданные: размер, флаги состояния.
```
Чанк
┌──────────────────────────────┐
│ Метаданные - несколько байт │ <- Размер, свободен/занят
├──────────────────────────────┤
│ Данные пользователя │ <- Указатель сюда
│ Например, объект SemVer │ возвращает malloc()
└──────────────────────────────┘
```
Свободные чанки связаны в списки — **бины** (bins, корзины). Хранение чанков в списках позволяет их переиспользовать и не отдавать ОС. А значит, сократить количество системных вызовов для выделения/освобождения памяти.
```
Бин Список свободных чанков
┌─────────────┐ ┌─────────┐ ┌────────┐
│ head ──┼─────> │ Чанк 1 │ │ Чанк 2 │
└─────────────┘ │ │ │ │
│ next ──┼───> │ next ─┼───> NULL
│ prev <─┼─────┼─ prev │
└─────────┘ └────────┘
```
Чанки можно назвать коробками, а бины — полками на складе. Тогда сам склад — это **арена** (arena). Она владеет участком памяти и стуктурами для управления ею — бинами. Для борьбы с фрагментацией бины сортированы по размеру их чанков.
```
Арена <- Менеджер участка памяти
│
Бины <- Списки свободных чанков
│
Чанки <- Фрагменты памяти
```
На старте программы создаётся главная арена. Если программа многопоточная, и потокам перестаёт хватать главной арены, то выделяются дополнительные для потоков. Это нужно, чтобы не скапливалась очередь на аллокацию из разных потоков.
```
Процесс
│
├── Главная арена ──┐
│ │ │
│ ├── Бины │
│ └── Свободная │
│ память │
│ │
├── Арена потока A │
│ │ │
│ ├── Бины │
│ └── Свободная │
│ память │
│ │
├── Арена потока B │
│ │ │
│ ├── Бины │
│ └── Свободная │
│ память │
└───────────────────┘
```
А теперь ответим на вопрос, чем грозит двойное освобождение памяти. Оно способно нарушить консистентность состояния менеджера памяти. Например, повредит метаданные чанка, если на момент повторного освобождения он уже занят. Или создаст цикл в списке свободных чанков: повторно добавленный чанк будет ссылаться на самого себя. В glibc достаточно хитрых проверок, чтобы предотвратить большинство подобных ситуаций. Но надеяться на это **нельзя.** При двойном освобождении может произойти всё что угодно, начиная с падения и заканчивая порчей данных.
## Уровень ОС: стратегия memory overcommitment
Если у программы не получается аллоцировать память, то:
- `malloc()` возвращает `nullptr`,
- `new` кидает исключение `std::bad_alloc`.
Это _почти_ наверняка случится, если запросить количество байт, заведомо превышающее объём всей физической памяти. Но в более сложных случаях нет гарантии, что аллокация завершится ошибкой! При нехватке памяти аллокация может пройти _якобы успешно,_ а проблемы начнутся в совершенно другом блоке кода _при обращении_ к этой памяти. Например, на старте сервиса выделяется большой блок памяти, а используется при запросах к сервису. В таком случае между возникновением проблемы (резервированием слишком большого куска памяти) и её симптомом (падением сервиса) может пройти несколько минут, а то и часов.
Почему так происходит? При вызове `malloc()` ОС выделяет виртуальный адрес, но не спешит привязывать к нему физическую память. ОС пробует выделить реальную страницу физической памяти, только когда по этому адресу начинается запись данных. Если на этот момент свободной памяти нет, ОС аварийно завершает программу. Причем не факт, что именно вашу.
Обещание памяти без гарантии её наличия называется [memory overcommitment](https://en.wikipedia.org/wiki/Memory_overcommitment). Эта стратегия особенно свойственна Linux и позволяет гибко распределять ресурсы: ОС раздаёт процессам «кредиты» в надежде, что они не используют все обещанные ресурсы одновременно. Если же процессы решают обналичить «чеки», а памяти не хватает, ОС принудительно завершает один из процессов.
## Резюме
- Выделение и освобождение памяти — это всегда парные действия.
- Утечка памяти возникает, если забыть освободить память.
- Двойное освобождение памяти или обращение к уже освобождённой памяти приводит к её повреждению и непредсказуемым последствиям (UB).
- `void *` — это универсальный указатель, ссылающийся на область памяти с данными любого типа.
- В C++ есть несколько способов для работы с динамической памятью:
- Сишные функции `malloc()` и `free()`.
- Выражения `new` и `delete`.
- Умные указатели.
- Успешное завершение `malloc()` или `new` не гарантирует, что память действительно отдана процессу. Чтобы в этом убедиться, нужно обратиться ко всей выделенной памяти.
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!