JavaRush /Курсы /C++ SELF /Heap — это очень интересно

Heap — это очень интересно

C++ SELF
38 уровень , 2 лекция
Открыта

1. Зачем программе динамическая память

Если вы только начинаете, слово heap звучит как имя финального босса в игре: «победи кучу, получи доступ к настоящему C++». На самом деле, сегодня цель спокойнее: понять, зачем программе нужна память, не привязанная к текущему вызову функции, и почему без неё невозможно написать даже простые удобные вещи вроде «списка задач неизвестной длины».

Что мы называем heap в учебной модели

Важно сразу уточнить терминологию. В реальной терминологии стандарта C++ чаще говорят про dynamic storage duration и иногда про free store, а слово «heap» — это бытовое название, которое хорошо прижилось у программистов, но не является строгим нормативным термином языка.

Даже в материалах по стандарту встречается мысль, что выражение “heap allocation” может вводить в заблуждение, потому что «heap» в разных местах означает разное, и лучше мыслить через “dynamic storage duration”.

В этой лекции слово «heap/куча» используется именно в учебном смысле: как «большое хранилище памяти, из которого можно брать куски во время выполнения, когда размер заранее неизвестен».

Почему стек не решает задачу переменного размера

Стек (stack) очень удобен: быстро создаёт локальные переменные, быстро их уничтожает, всё предсказуемо. Но у него есть железное ограничение: стек хорошо работает, когда вы примерно знаете, сколько памяти нужно, и когда время жизни данных естественно ограничено блоком {} или вызовом функции.

А теперь представьте «жизненную» задачу: пользователь вводит задачи в список. Сколько задач? Две? Двести? Две тысячи? Угадайте с трёх раз. Если вы попытаетесь заранее выделить массив на «максимум», вы либо выделите слишком мало (и сломаетесь), либо слишком много (и будете зря тратить память). Стек тут не виноват — он просто не предназначен быть «складом переменного размера».

Зафиксируем простую мысль: стек — про удобную и быструю память для временного, а динамическая память — про память для размера, который становится известен только во время выполнения.

2. Контейнеры и динамика без ручного управления

В интернете легко встретить ощущение, что «динамическая память» — это сразу про страшные вещи: new, delete, утечки и ночные кошмары. Хорошая новость: на нашем текущем уровне динамика почти всегда приходит через стандартные контейнеры, и именно так и надо.

Ключевая идея лекции: мы используем динамическую память там, где она оправдана, но делаем это через std::vector и std::string, потому что они уже умеют:

  • выделять память, когда нужно;
  • освобождать память автоматически;
  • переносить/копировать данные корректно;
  • хранить инварианты (например, что элементы лежат подряд в памяти у std::vector).

Строго говоря, стандарт формулирует это как «объекты имеют dynamic storage duration» и уточняет, что это относится к объектам, а не к «кускам памяти как таковым». Но в учебной речи мы будем говорить проще: «контейнер берёт память в куче под свои элементы».

Где живёт std::vector: объект отдельно, данные отдельно

Очень частая путаница у новичков звучит так: «если у меня std::vector, значит он в куче». Это не так. std::vector — обычный объект, и он живёт там, где вы его объявили: хоть на стеке (локальная переменная), хоть как static, хоть как поле структуры.

А вот его элементы обычно хранятся в динамической памяти, потому что количество элементов меняется во время выполнения.

Давайте посмотрим на мини-пример и одновременно начнём развивать наше приложение — консольный TaskBox (условный список задач).

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks; // сам объект tasks — локальный (обычно стек)
    tasks.push_back("Buy milk");    // строка и/или буфер вектора могут жить динамически
    std::cout << tasks.size() << '\n'; // 1
}

Обратите внимание: переменная tasks — локальная, то есть её время жизни ограничено блоком main(). Но при этом она может «под капотом» взять память из динамической области, чтобы хранить элементы. Это и есть главный практический смысл кучи: хранить то, что по размеру заранее не известно.

size и capacity: почему vector берёт память с запасом

Если вы кладёте элементы в std::vector по одному через push_back, вектор не обязан каждый раз выделять ровно «ещё одну ячейку». Это было бы слишком медленно: выделение памяти — операция сравнительно дорогая.

Поэтому вектор хранит два понятия: capacity — запас памяти, а size — сколько элементов реально сейчас лежит. Когда места не хватает, вектор выделяет новый, больший блок памяти, переносит элементы и освобождает старый.

Посмотрим на это глазами программы:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    std::cout << v.size() << " " << v.capacity() << '\n'; // 0 0 (часто)

    v.push_back(10);
    std::cout << v.size() << " " << v.capacity() << '\n'; // 1 ... (>=1)

    v.push_back(20);
    std::cout << v.size() << " " << v.capacity() << '\n'; // 2 ... (>=2)
}

Числа capacity() зависят от реализации стандартной библиотеки, поэтому не пытайтесь заучивать «будет 1, потом 2, потом 4». Важно другое: capacity обычно растёт скачками, и это нормально. Это цена за то, чтобы push_back в среднем работал быстро.

reserve: как честно сказать вектору «я знаю, что задач будет много»

Когда вы заранее примерно знаете объём данных, можно помочь std::vector и уменьшить количество перевыделений памяти. Для этого существует reserve(n): он говорит «подготовь память минимум под n элементов», но не делает вид, что элементы уже существуют.

Звучит абстрактно, поэтому давайте свяжем с TaskBox. Представим, что вы читаете из файла/ввода список задач и ожидаете около 100 строк. Тогда:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks;
    tasks.reserve(100); // просим память заранее

    tasks.push_back("Write code");
    tasks.push_back("Fix bug");
    std::cout << tasks.size() << '\n'; // 2
}

Почему это важно именно в теме heap? Потому что reserve влияет на то, как часто вектор будет обращаться к динамической памяти. Меньше обращений — меньше «переездов» данных — обычно быстрее и предсказуемее.

И ещё одна полезная граница: reserve — это «про память», а не «про элементы». Если вы сделаете reserve(100), size() не станет равным 100. Вектор просто подготовит место.

std::string и динамика: строка тоже растёт во время выполнения

Со строками та же история: почти никогда вы заранее не знаете, какой длины будет ввод пользователя. Поэтому std::string — это владеющий тип, который умеет работать с динамической памятью.

Мини-пример:

#include <iostream>
#include <string>

int main() {
    std::string s = "hi";
    s += " there";
    std::cout << s << '\n'; // hi there
}

Внутри std::string обычно есть буфер символов. Когда строка растёт, буфер может перевыделяться. На практике многие реализации оптимизируют маленькие строки (часто это называют SSO — small string optimization), и тогда короткая строка может вообще не трогать кучу.

Но это именно «часто», а не «обещание стандарта», поэтому в рассуждениях по надёжности лучше держать модель проще: строка — владеющий объект, который сам управляет памятью.

3. Когда динамическая память оправдана

Динамическая память не «хорошая» и не «плохая». Она как рюкзак: в походе необходим, но носить его на кухню за солью — странновато.

Три практических сценария

Первый сценарий — размер данных неизвестен на этапе компиляции. Это всё, что связано с вводом пользователя, файлами, сетью, обработкой текста. std::vector и std::string тут появляются естественно, потому что они умеют расти.

Второй сценарий — данных много. Даже если вы знаете, что у вас будет, например, 20000 элементов, держать их как локальный «огромный объект» внутри функции может быть неудобно и иногда опасно по ресурсам. Контейнеры позволяют аккуратно управлять этой памятью как ресурсом и обычно делают это через динамическую область.

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

Цена динамики: почему она не бесплатна

Если бы динамическая память была бесплатной, никто бы не любил стек: все бы всё хранили «в куче», и жизнь была бы проще. Но за гибкость мы платим.

Выделение памяти во время выполнения — это работа: нужно найти подходящий кусок, выдать его, возможно вести внутренние структуры учёта. Плюс у std::vector иногда происходят «переезды»: выделили новый блок, перенесли элементы, старый освободили.

Есть и приятная сторона: стандартные контейнеры как раз и придуманы, чтобы платить эту цену редко и аккуратно. capacity, стратегия роста и reserve — это инструменты, которые позволяют сделать поведение предсказуемее.

4. Практический пример: TaskBox и «динамика по делу»

Сейчас соберём небольшой кусочек TaskBox так, чтобы он подсветил смысл динамической памяти без лишней магии. Мы сделаем три операции: добавить задачу, вывести задачи, посчитать задачи. Хранить будем в std::vector<std::string>.

Начнём с функции добавления. Важно: держим всё очень простым — без файлов, без сложного парсинга.

#include <string>
#include <vector>

void add_task(std::vector<std::string>& tasks, const std::string& text) {
    tasks.push_back(text);
}

Эта функция выглядит скучно, но это комплимент. Она «передаёт ответственность» за память вектору: сколько бы задач ни добавили, контейнер сам возьмёт нужную память в динамической области.

Теперь сделаем печать:

#include <iostream>
#include <string>
#include <vector>

void print_tasks(const std::vector<std::string>& tasks) {
    for (std::size_t i = 0; i < tasks.size(); ++i) {
        std::cout << (i + 1) << ". " << tasks[i] << '\n';
    }
}

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

И теперь соберём минимальный main, который показывает рост:

#include <iostream>
#include <string>
#include <vector>

void add_task(std::vector<std::string>& tasks, const std::string& text) {
    tasks.push_back(text);
}

int main() {
    std::vector<std::string> tasks;
    tasks.reserve(8); // допустим, ожидаем небольшой список

    add_task(tasks, "Buy milk");
    add_task(tasks, "Learn C++");
    std::cout << tasks.size() << '\n'; // 2
}

Здесь heap проявляется в том, что tasks может увеличиваться по мере работы программы. На стеке вы бы такую гибкость не получили без фокусов вроде «выделить массив на 100000 и надеяться, что хватит».

Мини-схема: как думать о памяти без адресов и указателей

Иногда полезно иметь картинку в голове. Нарисуем очень условную схему. Это не про реальную ОС и не про «сегменты памяти», а про учебную модель ответственности и времени жизни.

Внутри функции (stack/automatic):

+-----------------------------+
| std::vector<std::string> v  |   <-- объект v живёт до конца scope
|  size = 2                   |
|  capacity = 8               |
|  (внутренние поля)          |
+-----------------------------+

Где-то в dynamic storage (heap/free store):

+-----------------------------------------------+
| буфер элементов для v: [string][string][... ] |
+-----------------------------------------------+

А у каждой std::string внутри тоже может быть свой буфер символов
(часто в dynamic storage, иногда оптимизировано).

Самое важное здесь — не «где лежит байт №17», а два вопроса, которые вы должны уметь задавать себе:

Первый вопрос: «кто владеет данными?». В примере владелец — std::vector и std::string.

Второй вопрос: «когда данные освобождаются?». На нашем текущем уровне ответ простой: когда уничтожается владелец (контейнер/строка), обычно на выходе из блока {}.

5. Типичные ошибки при работе с heap на уровне контейнеров

Ошибка №1: думать, что std::vector весь лежит в куче.
Обычно проблема тут не в памяти как таковой, а в ментальной модели. Если вы считаете, что сам vector где-то «улетает в космос», вы начинаете странно проектировать код, например боитесь передавать vector в функции или хранить его как локальную переменную. Правильнее помнить разделение: объект-контейнер живёт там, где объявлен, а его элементы — в динамической памяти, потому что их количество меняется.

Ошибка №2: путать reserve и resize.
reserve(n) просит подготовить память, но не добавляет элементы. resize(n) меняет количество элементов логически. Если перепутать, можно внезапно получить «пустые» элементы в конце, а потом долго удивляться, почему печатается что-то лишнее или почему алгоритм считает, что задач больше, чем вы добавляли.

Ошибка №3: ожидать, что память освобождается сразу, как стала не нужна.
Новички иногда мыслят так: «я добавил 1000 элементов, потом удалил 900 — значит память сразу вернулась системе». На практике контейнер может держать capacity как запас, потому что так выгоднее для производительности. На нашем уровне полезно помнить простое: главный момент освобождения ресурсов у владеющих объектов — это конец их времени жизни (например, выход из блока), то есть деструктор владельца.

Ошибка №4: много раз делать push_back без reserve, когда размер заранее известен.
Это не ошибка корректности: программа всё равно будет работать. Но иногда это превращает добавление в «переезды чемоданов» каждые несколько шагов. Если вы действительно знаете порядок величины (100, 1000, 100000), лучше один раз заранее подготовить память и жить спокойнее.

Ошибка №5: бояться динамики и пытаться заменить её фиксированными массивами на всякий случай.
Иногда люди пишут «у меня будет максимум 100 задач» и делают массив на 100. Потом жизнь вносит правки: задач 101, и начинается либо костыль, либо ограничение функциональности. std::vector существует ровно для того, чтобы не заставлять вас гадать про «максимум» там, где максимум вам не нужен как бизнес-ограничение.

1
Задача
C++ SELF, 38 уровень, 2 лекция
Недоступна
Слова до стопа
Слова до стопа
1
Задача
C++ SELF, 38 уровень, 2 лекция
Недоступна
Вместимость и размер
Вместимость и размер
1
Задача
C++ SELF, 38 уровень, 2 лекция
Недоступна
Заметки с пробелами
Заметки с пробелами
1
Задача
C++ SELF, 38 уровень, 2 лекция
Недоступна
Плюсы из потока
Плюсы из потока
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ