Загружаем лабораторные…
// C++ · Лекция 09

Виды памяти и управление ресурсами

Что хранится в стеке, что делает new, почему delete нельзя вызывать дважды и как работает static.

Карта памяти программы

Длительность хранения (storage duration) — свойство объекта, определяющее, как долго для него существует хранилище. В этой лекции изучаются статическая, автоматическая и динамическая длительности хранения.

Для первого понимания процесс можно представить как области с кодом, статическими данными, стеком и свободным хранилищем. В реальности это виртуальное адресное пространство со множеством отображений, а не один кусок RAM Почему не «единый непрерывный кусок»Современная ОС предоставляет процессу виртуальное адресное пространство. Страницы могут иметь разные права доступа, физически размещаться в разных местах и вообще не находиться непрерывно в RAM. Схема ниже — учебная модель..

СХЕМА / выбор области памяти

На схеме удобно искать ответ на вопрос «как долго существует объект?». Локальное имя, выделенная память и машинный код — разные части программы. Само наличие указателя ничего не говорит о времени жизни объекта: указатель можно передать из функции, а созданный через new объект продолжит существовать до освобождения (выход из функции сам по себе его не удаляет).

TEXTМашинный код
STATIC / DATAГлобальные и static
STACKКадры функций
DYNAMICnew / free store
Вызовы и локальные значения. Слот для локальной переменной — обычная реализация, а не обязанность стандарта.
Выделена выбранная областьАдреса условные
Модель не показывает ни реальные адреса, ни точные границы областей памяти.
ОбластьСодержимоеКогда существует
TEXT
Машинные инструкции программы
Обычно пока образ процесса загружен
STATIC / DATA
Глобальные объекты, статические локальные данные
Всё время выполнения программы
STACK / AUTO
Кадры вызовов, часть локальных переменных
При выполнении блоков и вызовов
DYNAMIC / HEAP
Объекты, созданные через new
До соответствующего освобождения
Статическая память ≠ стек

Стек обычно обслуживает объекты с автоматической длительностью хранения; объекты со статической длительностью хранения живут до завершения программы. Это разные свойства, а не названия одного сегмента.

Стек, области видимости и вызовы

Стек вызовов (call stack) — механизм выполнения вложенных вызовов функций. Обычно организован как набор кадров, где есть локальные данные и информация для возврата к вызывающему коду.

Вложенные блоки

При входе в блок появляется локальный y, при выходе его время жизни заканчивается. Когда вызывается f(x), появляется новый кадр со своим параметром и адресом возврата Указатель стекаВо многих архитектурах специальный регистр хранит текущую вершину стека. Реальное расположение параметров и адресов возврата зависит от ABI, архитектуры и оптимизаций; компилятор не обязан класть каждую переменную в стек..

Кадр стека / stack frame

Набор данных одного вызова функции. Кадры обычно добавляются при вызове и снимаются при возврате.

stack.cpp
#include <iostream>
void f(int a) {
    std::cout << a + 1;
}
int main() {
    int x = 5;
    {
        int y = 9;
        (void)y;
        f(x);
    } // время жизни y закончено
}
Фигурные скобки задают блочную область видимости. Параметр a — отдельный объект.
Визуализация / ход исполнения программы

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

ВЕРШИНА СТЕКА ↑
mainx = 5
ниже — данные других кадров / база стека
1 / 5

Создана локальная переменная main.

Условный стек: компилятор вправе размещать и оптимизировать локальные объекты иначе.

Переходы описывают время жизни объектов, а не точную последовательность машинных команд.
Адрес и время жизни — разные вещи

После выхода из блока объект y не существует. Даже если прежние байты не перезаписаны, разыменование указателя на него недопустимо. Освободившийся стек может использоваться снова.

Конечная рекурсия: база, шаг и возврат

Функция вызывает себя для меньшей задачи. Для sum_to(n) база — n=0, результат 0. Для n>0 результат равен n+sum_to(n−1). Контракт: целое n от 0 до 100. Аргумент уменьшается, поэтому база достигается; число промежуточных результатов ограничено.

finite_recursion.cpp
#include <iostream>
int sum_to(int n) {
    if (n == 0) return 0;
    return n + sum_to(n - 1);
}
int main() { std::cout << sum_to(4) << '\n'; }
Контракт n=0…100. Есть база и шаг к ней; затем возвращается результат.
Результат10
ВызовЖдёт результатВозвращает
sum_to(3)
3 + sum_to(2)
6
sum_to(2)
2 + sum_to(1)
3
sum_to(1)
1 + sum_to(0)
1
sum_to(0)
База
0

При раскрытии вызовов результат ещё не готов. При возврате сначала получается 0, затем 1, 3 и 6. Задание: выпишите раскрытие и возвраты для n=4 и проверьте 0/1/4. Затем напишите факториал n≤12 с результатом long long. Аварийное переполнение стека изучается отдельно и не является способом вычисления результата.

Динамическое выделение: new и delete

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

new int(5)создать и инициализировать
→
*p = 7использовать объект
→
delete pуничтожить и освободить

Сам указатель может быть локальной переменной (например, храниться в стеке), а объект, на который он указывает, имеет динамическую длительность хранения.

new int без инициализатора оставляет значение целого неопределённым; new int(5) создаёт целое со значением 5; new int{} даёт 0. new возвращает адрес объекта, а не «первого байта»С точки зрения C++ выражение new T возвращает указатель типа T* на созданный объект T, а не нетипизированный адрес. Создание может включать вызов конструктора. Если выделение памяти не удаётся, обычный new бросает std::bad_alloc.

new_delete.cpp
int main() {
    int* p = new int(5);
    *p = 7;
    delete p;
    p = nullptr;
}
Указатель p локален; объект *p живёт с new до delete.
Визуализация / указатель и динамический объект

После new важно следить за двумя сущностями: переменной-указателем и объектом, адрес которого она хранит. delete уничтожает выделенный объект, но не присваивает указателю nullptr автоматически. Зануление делает состояние этого указателя яснее; другие копии старого адреса от этого не становятся безопасными.

Локальный p
nullptr
Динамический int
нет объекта

Переменная p и объект *p имеют разную длительность хранения.

Пока нет выделения.
После delete память больше не твоя

delete p завершает жизнь выделенного объекта. Хранящееся в p прежнее значение нельзя использовать для доступа; безопасно занулить p = nullptr, если это полезно в данном месте.

Владение ресурсом и RAII

Важно не только получить адрес объекта, но и знать, кто обязан освободить занятый ресурс. Обычный указатель сам по себе эту обязанность не выполняет. В учебном примере с new программист должен был вызвать соответствующий delete на нужном пути исполнения. Для повседневного C++ чаще выбирают объект-владелец. Unique_ptr хранит владение одним динамическим объектом и освобождает его, когда сам прекращает существование. Make_unique создаёт такой объект и возвращает владельца. В функции ниже есть ранний return, но оба выхода разрушают локального владельца, поэтому отдельные вызовы delete не нужны. Это пример RAII: ресурс связан со временем жизни управляющего объекта. Полученный через *owner доступ не продлевает жизнь динамического объекта отдельно от владельца. Если сохранить указатель или ссылку наружу, после уничтожения владельца пользоваться ими нельзя. Для последовательности чисел обычно выбирают vector; unique_ptr здесь объясняет владение одним объектом.

code-guided-owner.cpp
#include <iostream>
#include <memory>

int readValue(bool useValue) {
    auto owner = std::make_unique<int>(5);
    if (!useValue) {
        return 0;
    }
    return *owner;
}

int main() {
    std::cout << readValue(false) << ' ' << readValue(true) << '\n';
}
Владелец освобождает int при обоих выходах из функции.
Результат0 5

Для массива значений выбирайте vector. Наблюдающий указатель или ссылка не удерживают ресурс; после разрушения владельца доступ к цели недопустим. Сам int в простой программе обычно вообще не требует динамического выделения: здесь оно нужно только для объяснения владельца.

Динамические массивы

new[] выделяет последовательность

new int[n] создаёт массив из n элементов и возвращает указатель на первый. В пределах массива допускается арифметика указателей и доступ по индексам 0 … n−1.

Форма удаления должна соответствовать форме создания: для new[] используется delete[].

Инициализация массива / array initialization

new int[n]{} обнуляет все элементы; new int[n] без инициализатора не задаёт значения элементов int.

array_new.cpp
#include <cstddef>
int main() {
    std::size_t n = 5;
    int* a = new int[n]{}; // все нули
    a[2] = 17;
    delete[] a;
}
new[] и delete[] — обязательная пара.
Визуализация / размер массива и корректные индексы

Динамический размер не отменяет границы массива. Для n элементов допустимы индексы от 0 до n − 1; указатель a + n служит границей, а не адресом ещё одного доступного элемента. После delete[] недоступен весь массив. Для прикладного кода обычно удобнее std::vector: он помнит свой размер и сам освобождает память.

a + 0 → a[0]

Выбран элемент с корректным индексом: доступ к нему определён.

Корректный индекс массива.
СозданиеОсвобождениеСтатус
new int(5)
delete p
Корректная пара
new int[5]{{}}
delete[] a
Корректная пара
new int[5]
delete a
UB — несовпадение форм
new int(5)
delete[] p
UB — несовпадение форм

Утечки памяти и ошибки освобождения

Утечка памяти (memory leak) — ситуация, когда программа оставляет выделение неосвобождённым, теряя возможность выполнить корректное освобождение. Особенно опасна в длительно работающих процессах.

Если функция создаёт объект через new и выходит, не передав владение и не вызвав delete, локальный указатель исчезает, но выделение остаётся.

Висячий указатель / dangling pointer

Адрес сохранён, но объекта по нему больше нет. В отличие от утечки указатель здесь остался, а объект уже уничтожен.

memory_leak.cpp
void bad() {
    int* p = new int(5);
    // забыли delete p;
} // указатель p исчез

void good() {
    int* p = new int(5);
    delete p;
}
В bad() выделение продолжает существовать после завершения функции.
Визуализация / серия вызовов функции

Утечку удобно понимать как потерю владельца: объект ещё выделен, а программа уже не знает, кто и когда его освободит. Это отличается от висячего указателя, при котором адрес сохранился после уничтожения объекта. Модель накапливает условные выделения; примеры кода показывают два способа завершать каждый цикл без утечки.

0утёкших объектов
0.00 KiBзанято ими (int≈4Б)
0объектов освобождено

Шкала показывает долю от условных 64 KiB. Каждый вызов f() выполнил new int(5), но локальный указатель уничтожился при выходе. В безопасном варианте выделения сразу освобождаются.

Даже маленькая утечка в горячем цикле постоянно увеличивает занятые ресурсы.
ОшибкаЧто не такРезультат
Повторный delete
Одна и та же память освобождается дважды
UB
Неправильный указатель
Передан адрес внутри выделения либо стековой переменной
UB
delete вместо delete[]
Несоответствие вида выделения
UB
Нет освобождения
Выделение теряется, объект остаётся выделенным
Утечка
delete p, q;
Из-за приоритета операторов удалится лишь p
q не будет освобождён
Важно: утечка не равна UB

Отсутствие delete само по себе не означает неопределённое поведение. Оно расходует ресурсы и может привести к исчерпанию памяти. Но неверный delete или доступ после удаления — уже UB.

Локальные статические переменные

Локальная статическая переменная — объект с блочной областью видимости, но со статической длительностью хранения. Его значение сохраняется между вызовами функции.

В обычной локальной переменной int x = 0; новое значение 0 возникает при каждом вызове. Если написать static int x = 0;, память предназначена для одного объекта на всю программу, и следующие вызовы используют тот же x.

Инициализация выполняется один раз: для постоянного нуля она может произойти ещё до первого вызова, а для динамического инициализатора — при первом прохождении объявления Когда именно инициализируется local staticКонстантная / нулевая инициализация может выполняться до первого захода. Инициализация, требующая вычислений времени выполнения, выполняется при первом прохождении объявления; начиная с C++11 она потокобезопасна. В случае исключения при инициализации при следующем заходе выполняется новая попытка..

static.cpp
#include <iostream>
void f() {
    int x = 0;
    static int count = 0;
    ++x;
    ++count;
    std::cout << x << ' ' << count << '\n';
}
int main() {
    f(); f(); f(); // 1 1; 1 2; 1 3
}
Локальный static сохраняет состояние между вызовами.
Визуализация / повторный вход в функцию

У static внутри функции есть два независимых свойства: имя видно только в своём блоке, а объект сохраняется между вызовами. Это похоже на записную книжку функции (новый вызов видит предыдущую запись). Обычная локальная переменная, напротив, создаётся заново; её инициализатор выполняется при каждом прохождении объявления.

—обычный int x
0static int count
0вызовов f()
automatic xещё не создавался
static countожидает использования

Для int x = 0; ++x; при каждом вызове получается 1. Для static int count = 0; ++count; значения идут 1, 2, 3, …

Статическая переменная имеет локальную область видимости, но время хранения охватывает весь запуск программы.
static и const говорят о разном

static здесь определяет длительность хранения; const запрещает изменять объект через соответствующее имя/ссылку. Они не исключают друг друга.

Почему переполняется стек

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

Переполнение стека / stack overflow

Исчерпание доступного пространства стека. В типичных системах проявляется аварийным завершением, но точное поведение зависит от платформы и настройки исполнения.

Ограничение часто составляет несколько мегабайт, но 8 МиБ — не универсальное требование C++. Размер зависит от ОС, потока, лимитов и настроек запуска.

recursion.cpp
#include <iostream>
int sum_to(int n) {
    if (n == 0) return 0;
    return n + sum_to(n - 1);
}
int main() {
    std::cout << sum_to(4) << '\n';
}
// Вызов без базы f(x+1) не запускаем: он не решает конечную задачу.
Конечная рекурсия для n от 0 до 100: база, шаг к базе и возврат результата. Вывод: 10.
Визуализация / модель расходования стека

Расход стека зависит и от числа одновременно активных вызовов, и от данных каждого вызова (грубо: глубина × размер кадра). Ползунки позволяют менять оба множителя без настоящего аварийного завершения. В примерах ниже одна задача решена рекурсией с условием выхода и циклом: сравните, какая информация должна сохраняться между шагами.

16вызовов
128байт / кадр
64 KiBусловный лимит
2.0 KiBзанято
64 KiBлимит
В ПРЕДЕЛАХстатус
Глубина увеличивает использование стека. Это только модель с выбранными вручную параметрами.
Рекурсия не всегда обязана расходовать стек линейно

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

+Дополнительно⌄

Четыре длительности хранения, а не три сегмента

Стандарт C++ различает automatic, static, dynamic, а также thread storage duration для thread_local. Слова «стек», «куча» и «data» описывают распространённые механизмы реализации и раскладку исполняемого файла. Например, компилятор может хранить локальное число в регистре, вообще не выделяя для него слот в стеке.

Реальные секции образа процесса

Реализации обычно различают .text, .rodata, .data, .bss и другие секции. Стек каждого потока и динамические отображения могут находиться далеко друг от друга. Размер стека не зафиксирован стандартом и обычно индивидуален для потока.

Откуда new получает память?

Выражение new вызывает функцию выделения памяти (operator new), которая часто пользуется библиотечным аллокатором и лишь при необходимости запрашивает новые страницы у ОС. Каждый new не обязан делать отдельный системный вызов. Аналогично, delete не гарантирует немедленный возврат страниц ОС.

Сборщик мусора и владение ресурсами

В C++ по умолчанию нет обязательного сборщика мусора для new-объектов. Но современный стиль почти никогда не требует вручную писать new/delete для прикладного кода: std::vector хранит массив, std::unique_ptr владеет единичным объектом, а RAII связывает освобождение с деструктором владельца.

modern_cpp.cpp
#include <memory>
#include <vector>

void example() {
    std::vector<int> a(1000); // сам освободит массив
    auto p = std::make_unique<int>(5);
    *p = 7;
} // автоматическое освобождение
Современная практика: избегаем ручного delete с помощью владеющих типов.

Поиск ошибок

При запуске с AddressSanitizer часто обнаруживаются use-after-free, buffer overflow и некоторые утечки. Пример:

build.sh
# g++ -std=c++20 -g -O1 -Wall -Wextra \
#   -fsanitize=address,undefined \
#   demo.cpp -o demo
# ./demo
Команда для работы с локальным компилятором, не кнопка выполнения в HTML.

Диагностика зависит от компилятора, ОС и конкретной ошибки; ни один санитайзер не доказывает полное отсутствие UB.

Термины

Storage duration

Длительность хранения

Категория времени, в течение которого предоставлено хранилище объекта: автоматическая, статическая, динамическая, а также thread-local.

Примеры кода

Конечная рекурсия с проверенным контрактом

recursion-cases.cpp
#include <iostream>
int sum_to(int n) {
    if (n == 0) return 0;
    return n + sum_to(n - 1);
}
int main() {
    int n = 0;
    if (!(std::cin >> n) || n < 0 || n > 100) {
        std::cerr << "Expected 0..100\n";
        return 1;
    }
    std::cout << sum_to(n) << '\n';
}

Владелец освобождает int при обоих выходах из функции.

code-guided-owner.cpp
#include <iostream>
#include <memory>

int readValue(bool useValue) {
    auto owner = std::make_unique<int>(5);
    if (!useValue) {
        return 0;
    }
    return *owner;
}

int main() {
    std::cout << readValue(false) << ' ' << readValue(true) << '\n';
}

Обсудить лекцию с ИИ

Скопируйте промпт в свой ИИ-чат. В него уже включены материал этой лекции и примеры кода — можно сразу задавать вопросы и тренироваться.

Посмотреть промпт