Главная / Курсы / C++ по спирали / Глава 15. Указатели / Динамическое выделение памяти
# Глава 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()` обнулять указатель: `arr = nullptr`. Это позволяет избежать двойного освобождения памяти. Мы этого не сделали, потому что на `free()` наша программа заканчивается. ### 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)); 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(dst)) { 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 ``` ## На уровень выше: выражения 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.) чтобы посмотреть в консоли стадии жизни объекта: ```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 ver = nullptr; ``` Но в нашем примере этого не потребовалось. Есть ли в этом примере кода ошибки управления памятью? Введите: `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 ``` ## Выражения new[] и delete[] для массивов Если сишный массив создан глобально или помечен как `static`, то располагается в статической области памяти. В остальных случаях такой массив живет в автоматической памяти. Поэтому у него константная длина: нужно знать заранее, сколько статической или автоматической памяти нужно резервировать. Есть способ превратить сишный массив в динамический и создать его на куче. Для этого используются версии операторов с квадратными скобками [new[]](https://en.cppreference.com/w/cpp/memory/new/operator_new.html) и [delete[]](https://en.cppreference.com/w/cpp/memory/new/operator_delete.html). Если аллоцировать массив в динамической памяти, требование к константности длины пропадает. Так выглядит создание неинициализированного массива и его уничтожение: ```cpp {.example_for_playground .example_for_playground_009} int n = 5; int * arr = new int[n]; // ... Заполняем массив, работаем с ним delete[] arr; ``` Квадратные скобки `delete[]` всегда пусты. В квадратные скобки выражения `new T[n]` может быть передано любое целое неотрицательное число. Оно не обязано быть константой. Допустимо выделение массива нулевого размера: `new T[0]`. Но раз такой массив не содержит элементов, разыменовывать на него указатель запрещено. Такая на первый взгляд странная возможность может пригодиться в ряде случаев. Например, чтобы не писать лишних проверок: ```cpp if (n > 0) arr = new T[n]; else arr = nullptr; ``` Во-вторых, в отличие от нулевого указателя, такой массив имеет корректный адрес, и его можно передавать в алгоритмы стандартной библиотеки. Возможность создавать динамические массивы нулевой длины отличает их от статических массивов. При попытке завести обычный сишный массив нулевой длины компилятор вернет ошибку. Чтобы инициализировать массив, в фигурных скобках перечисляются значения элементов: ```cpp double * arr = new double[3]{1.0, 2.2, 7.8}; delete[] arr; ``` ## Работа с динамической памятью на примере вектора Теперь у нас есть все необходимое, чтобы написать свою реализацию вектора целых чисел. Она будет примитивной, но работоспособной. Сначала опишем интерфейс класса. А потом добавим реализацию методов. ```cpp class Vector { public: Vector() = default; // Создает вектор из n элементов со значением val Vector(std::size_t n, int val); ~Vector(); // Возвращает количество элементов std::size_t size(); // Возвращает емкость - под сколько элементов // выделена память std::size_t capacity(); // Возвращает элемент по индексу. Если индекс за // пределами вектора, кидает исключение int & at(std::size_t i); // Возвращает элемент по индексу int & operator[](std::size_t i); // Увеличивает емкость вектора: выделяет под него // память бОльшего размера. Если значение capacity // меньше текущей емкости, то ничего не делает void reserve(std::size_t capacity); // Изменяет реальный размер вектора. Если новый размер // меньше текущего, удаляет элементы с конца. Если // больше, то добавляет элементы, инициализированные // значением по умолчанию. Если размеры равны, то // ничего не делает void resize(std::size_t size); // Добавляет элемент в конец void push_back(int val); // Удаляет последний элемент void pop_back(); private: // Указатель на сишный массив, в котором // хранятся элементы int * m_elements = nullptr; // Реальное количество элементов std::size_t m_size = 0; // Емкость: под сколько элементов выделена память std::size_t m_capacity = 0; }; ``` Обратите внимание на метод `int & operator[](std::size_t i)`: он нужен для обращения к объекту `Vector` через оператор взятия по индексу `[]`. Подробнее про операторы будет в следующих главах. Поведение методов класса `Vector` приблизим к [аналогичным методам](https://en.cppreference.com/w/cpp/container/vector.html) `std::vector`. Чтобы посмотреть полный код класса `Vector`, откройте этот пример в песочнице. Дальше мы будем последоваельно разбирать его методы. ```cpp {.example_for_playground .example_for_playground_002} Vector v(4, 2); v.reserve(15); v.push_back(3); for(std::size_t i = 0; i < v.size(); ++i) std::println("{}", v[i]); ``` Заведем перегрузку конструктора для инициализации вектора `n` элементами, равными `val`. ```cpp Vector::Vector(std::size_t n, int val): m_elements{new int[n]}, m_size{n}, m_capacity{n} { for (std::size_t i = 0; i < m_size; ++i) m_elements[i] = val; } ``` Выражение `new int[n]` выделяет память под сишный массив. Указатель на эту память инициализирует поле `m_elements` в [списке инициализации полей.](/courses/cpp/chapters/cpp_chapter_0122/#block-member-initializer-list) Улучшите эту реализацию конструктора: {.task_text} - Для присваивания элементам значений вместо цикла используйте алгоритм [std::fill()](https://en.cppreference.com/w/cpp/algorithm/fill.html). - Выделяйте и заполняйте массив не в теле конструктора, а в списке инициализации. Инициализация всех полей в списке инициализации конструктора — очень хорошая практика. Тело конструктора при этом остается пустым. - Для этого напишите вспомогательную функцию `make_array()`, которая аллоцирует массив из `n` элементов, заполняет его с помощью `std::fill()` и возвращает на него указаель. ```cpp {.task_source #cpp_chapter_0154_task_0040} int * make_array(std::size_t n, int val) { } Vector::Vector(std::size_t n, int val) { } ``` В списке инициализации присвойте указателю `m_elements` значение, которое возвращает функция `make_array()`. {.task_hint} ```cpp {.task_answer} int * make_array(std::size_t n, int val) { int * elements{new int[n]}; std::fill(elements, elements + n, val); return elements; } Vector::Vector(std::size_t n, int val): m_elements{make_array(n, val)}, m_size{n}, m_capacity{n} { } ``` Конструктор готов. Пример его вызова для создания вектора из 5 элементов со значением 9: ```cpp Vector v(5, 9); ``` Сразу же определим деструктор: ```cpp Vector::~Vector() { delete[] m_elements; } ``` Методы `size()` и `capacity()` просто возвращают значения соответствующих полей: ```cpp std::size_t Vector::size() { return m_size; } std::size_t Vector::capacity() { return m_capacity; } ``` Метод `at()` принимает индекс элемента и возвращает на него ссылку. Если индекс выходит за границы массива, будет брошено исключение: ```cpp int & Vector::at(std::size_t i) { if (i >= m_size) { throw std::out_of_range( std::format("Index {} is out of bounds. Vector size: {}", i, m_size)); } return m_elements[i]; } ``` Есть ли в этом коде утечка памяти? ```cpp {.example_for_playground .example_for_playground_011} int main() { Vector numbers(4, 100); std::println("{}", numbers.at(5)); } ``` Да, есть! Мы завели вектор из 4-х элементов и обратились по индексу 5. Метод `at()` бросил исключение, и деструктор `Vector` не вызвался. А освобождение памяти по указателю `m_elements` организовано именно в деструкторе! Но утечка памяти тут чисто формальная. На практике она не интересна, потому что не обработанное исключение приводит к завершению программы. В этот момент отданная процессу память возвращается операционной системе. Как насчет утечки в таком коде? ```cpp {.example_for_playground .example_for_playground_010} int main() { Vector numbers(4, 100); try { std::println("{}", numbers.at(5)); } catch(const std::out_of_range & e) { std::println("Couldn't get element from vector: {}", e.what()); } } ``` В этом коде нет утечек памяти. Брошенное исключение перехватывается и происходит раскрутка стека (stack unwinding): - Со стека удаляются фреймы всех функций до той, которая обрабатывает исключение. - При снятии фрейма со стека происходит автоматическое удаление переменных из этого фрейма. Вызываются их деструкторы. - Выполнение кода внутри блока `try` прерывается и управление передается блоку `catch`. Для локальных переменных блока `try` вызываются деструкторы. Это означает, что для объекта `numbers` будет вызван деструктор. Вы можете открыть пример кода в песочнице и удостовериться в этом. Реализация оператора `[]` даже проще метода `at()`: ```cpp int & Vector::operator[](std::size_t i) { return m_elements[i]; } ``` Метод `reserve()` увеличивает емкость вектора: выделяет под него память нового размера. Если новый размер меньше текущей емкости, то метод ничего не делает. ```cpp void Vector::reserve(std::size_t new_capacity) { if (new_capacity <= m_capacity) return; int * new_elements = new int[new_capacity]; // Копирование for (std::size_t i = 0; i < m_size; ++i) new_elements[i] = m_elements[i]; // Обмен указателей int * tmp = new_elements; new_elements = m_elements; m_elements = tmp; delete[] new_elements; m_capacity = new_capacity; } ``` Что происходит в этом методе? Мы: - Завели новый массив `new_elements` подходящего размера. - Скопировали в него элементы из старого массива. - Обновили значение указателя `m_elements`, чтобы он ссылался на новую область памяти. - Освободили ненужную область памяти. Важно, что _за_ последним скопированным элементом в новом массиве храниться произвольный мусор. Улучшите метод `reserve()`: {.task_text} - Замените цикл с поэлементным копированием элементов на алгоритм [std::copy()](https://en.cppreference.com/w/cpp/algorithm/copy.html). - Замените обмен указателей через временную переменную на вызов [std::swap()](https://en.cppreference.com/w/cpp/utility/swap.html). ```cpp {.task_source #cpp_chapter_0154_task_0050} void Vector::reserve(std::size_t new_capacity) { } ``` Копирование элементов с помощью алгоритма: `std::copy(m_elements, m_elements + m_size, new_elements)`. {.task_hint} ```cpp {.task_answer} void Vector::reserve(std::size_t new_capacity) { if (new_capacity <= m_capacity) return; int * new_elements = new int[new_capacity]; std::copy(m_elements, m_elements + m_size, new_elements); std::swap(m_elements, new_elements); delete[] new_elements; m_capacity = new_capacity; } ``` Перейдем к реализации метода `resize()`. ```cpp void Vector::resize(std::size_t size) { if (size <= m_size) { m_size = size; return; } reserve(size); std::fill(m_elements, m_elements + size, int{}); } ``` Теперь напишем метод для добавления элемента в конец вектора. При исчерпании емкости будем ее удваивать. ```cpp void Vector::push_back(int val) { if (m_size + 1 > m_capacity) reserve(std::max(m_capacity, 1uz) * 2); m_elements[m_size++] = val; } ``` Обратите внимание на использование [постфиксной формы](/courses/cpp/chapters/cpp_chapter_0022/#block-post-increment) инкремента `m_size++`, которая сначала возвращает значение, а потом увеличивает его. Поэтому в выражении `m_elements[m_size++]` нет выхода за границы массива. Итак, мы написали простой прототип класса вектора с минимальным набором методов. Для этого использовали указатель на массив и два числа: реальный размер вектора `size` и его вместимость `capacity`. Интересный факт: большинство стандартных реализаций `std::vector` вместо одного указателя и двух чисел задействуют три указателя: на начало массива, на последний элемент и на конец выделенной под массив памяти. Такой подход эффективен при вставке и удалении с конца. ## Работа с динамической памятью на примере двусвязного списка В главе про основы работы с указателями вы уже успели порешать задачи, связанные [со структурой](/courses/cpp/chapters/cpp_chapter_0152/#block-listnode) `ListNode`. Она реализует узел односвязного списка: ```cpp struct ListNode { ListNode() {} explicit ListNode(int value) : val(value) {} explicit ListNode(int value, ListNode * next_node) : val(value), next(next_node) {} int val = 0; ListNode * next = nullptr; }; ``` А теперь представьте класс односвязного списка, состоящий из узлов `ListNode`: ```cpp class List { public: List() = default; ~List(); // Возвращает длину списка std::size_t len(); // Добавляет новый узел со значением val в начало void push_front(int val); // Ищет элемент со значением val, удаляет его и возвращает true. // Если такой элемент не найден, ничего не делает и возвращает false void remove(int val); // Возвращает элемент по индексу. Если индекс за пределами // списка, возвращает nullptr ListNode * get(std::size_t index); private: // Указатель на первый элемент списка ListNode * head = nullptr; // Длина списка std::size_t size = 0; }; ``` Реализуйте методы класса `List`. {.task_text} ```cpp {.task_source #cpp_chapter_0154_task_0060} List::~List() { } std::size_t List::len() { } void List::push_back(int val) { } bool List::remove(int val) { } ListNode * List::get(std::size_t index) { } ``` Чтобы написать деструктор, нужно понять, как в цикле удалить все узлы списка. Для этого пока `head != nullptr` делайте следующее: заводите временный указатель `ListNode * tmp = head`. Затем сдвигайте голову списка: `head = head->next`. И наконец освобождайте память по временному указателю: `delete tmp`. Он указывает на голову списка. {.task_hint} ```cpp {.task_answer} List::~List() { while (head != nullptr) { ListNode * tmp = head; head = head->next; delete tmp; } } std::size_t List::len() { return size; } void List::push_front(int val) { ListNode * new_head = new ListNode{val}; new_head->next = head; head = new_head; ++size; } bool List::remove(int val) { if (head == nullptr) return false; if (head->val == val) { ListNode* tmp = head; head = head->next; delete tmp; --size; return true; } ListNode * cur = head; while (cur->next != nullptr && cur->next->val != val) cur = cur->next; if (cur->next) { ListNode * tmp = cur->next; cur->next = cur->next->next; delete tmp; --size; return true; } return false; } ListNode * List::get(std::size_t index) { ListNode * cur = head; std::size_t i = 0; while (cur != nullptr) { if (i == index) return cur; cur = cur->next; ++i; } return nullptr; } ``` ## Нехватка памяти Если у программы не получается аллоцировать память, то: - `malloc()` возвращает `nullptr`, - `new` кидает исключение `std::bad_alloc`. Это _почти_ наверняка случится, если запросить количество байт, заведомо превышающее объем всей физической памяти. Но в более сложных случаях нет гарантии, что аллокация завершится ошибкой! При нехватке памяти аллокация может пройти _якобы успешно,_ а проблемы начнутся в совершенно другом блоке кода _при обращении_ к этой памяти. Например, на старте сервиса выделяется большой блок памяти, а используется при запросах к сервису. В таком случае между возникновением проблемы (резервированием слишком большого куска памяти) и ее симптомом (падением сервиса) может пройти несколько минут, а то и часов. Почему так происходит? При вызове `malloc()` ОС выделяет виртуальный адрес, но не спешит привязывать к нему физическую память. ОС пробует выделить реальную страницу физической памяти, только когда по этому адресу начинается запись данных. Если на этот момент свободной памяти нет, ОС аварийно завершает программу. Причем не факт, что именно вашу. Обещание памяти без гарантии ее наличия называется [memory overcommitment](https://en.wikipedia.org/wiki/Memory_overcommitment). Эта стратегия особенно свойственна Linux и позволяет гибко распределять ресурсы: ОС раздает процессам «кредиты» в надежде, что они не используют все обещанные ресурсы одновременно. Если же процессы решают обналичить «чеки», а памяти не хватает, ОС принудительно завершает один из процессов. ---------- ## Резюме - Выделение динамической памяти называется аллокацией. - Выделение и освобождение памяти — это всегда парные действия. - Утечка памяти возникает, если забыть освободить память. - Двойное освобождение памяти или обращение к уже освобожденной памяти приводит к ее повреждению и непредсказуемым последствиям (UB). - В C++ есть несколько способов для работы с динамической памятью: - Сишные функции `malloc()` и `free()`. - Выражения `new` и `delete`. - Умные указатели. - Успешное завершение `malloc()` или `new` не гарантирует, что память действительно отдана процессу. Чтобы в этом убедиться, нужно обратиться ко всей выделенной памяти.

Следующие главы находятся в разработке

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!