JavaRush /Курсы /C++ SELF /Циклы владения — как возникает утечка “без delete”

Циклы владения — как возникает утечка “без delete”

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

1. Почему “умные указатели” всё равно могут “течь”

Когда вы впервые слышите про shared_ptr, в голове почти автоматически появляется мысль: “Ну всё, утечки отменяются, можно выкинуть слово delete из словаря, а память сама разберётся”. И да, в 90% случаев так и будет. Но есть один хитрый сценарий, где shared_ptr ведёт себя как слишком вежливый гость: он никак не уйдёт, потому что его “держат за руку” другие shared_ptr.

Чтобы понять, что ломается, нужно вспомнить механизм shared_ptr: это владение через счётчик. Каждый новый владелец увеличивает счётчик, каждый уходящий — уменьшает. И объект уничтожается только тогда, когда счётчик становится равен нулю. А теперь представьте, что два объекта владеют друг другом. Их счётчики никогда не упадут до нуля — даже когда вы “всё отпустили” в main().

Цикл владения: определение “на пальцах”

Цикл владения — это ситуация, когда есть замкнутая цепочка объектов, и каждый держит другого через владеющую ссылку (в нашем дне это std::shared_ptr). В итоге каждый объект “обязан жить”, потому что на него кто-то ссылается как владелец. И так по кругу.

Важно поймать тонкую мысль: проблема тут не в том, что “есть ссылки”. Ссылки — это нормально. Проблема именно в том, что ссылки владеющие. То есть они продлевают жизнь объекта. Если продление жизни замкнулось в кольцо, то система подсчёта ссылок не может “выпилить” это кольцо автоматически: счётчики не дойдут до 0 никогда.

Ниже — очень жизненная визуализация:

flowchart LR
    A[Task] -- shared_ptr --> B[Column]
    B -- shared_ptr --> A
    %% Итог: счётчики не становятся 0

2. Как увидеть утечку: деструктор как детектор

Слова “утечка памяти” звучат абстрактно, пока не увидишь вживую: “я вышел из области видимости, а деструктор так и не вызвался”. Поэтому сегодня мы будем делать очень инженерную вещь: добавим в struct деструктор, который печатает сообщение. Это почти как сигнализация: если “не пропищало”, значит объект не уничтожился.

Начнём с мини-структуры, просто чтобы привыкнуть к приёму:

#include <iostream>

struct Loud {
    int id{};

    ~Loud() {
        std::cout << "~Loud(" << id << ")\n";
    }
};

int main() {
    Loud x{1};
    std::cout << "End of main\n"; // End of main
} // ~Loud(1)

Здесь всё ожидаемо: x — обычный объект на стеке, при выходе из main деструктор вызывается.

Теперь сделаем то же самое, но “под управлением” shared_ptr, чтобы вспомнить, что всё тоже должно корректно завершаться:

#include <iostream>
#include <memory>

struct Loud {
    int id{};
    ~Loud() { std::cout << "~Loud(" << id << ")\n"; }
};

int main() {
    auto p = std::make_shared<Loud>(Loud{7});
    std::cout << "End of main\n"; // End of main
} // ~Loud(7)

Пока владелец один, всё хорошо: p разрушился, счётчик стал 0, объект уничтожился.

3. Цикл на практике: Task и Column в мини‑приложении

Представим, что мы развиваем учебное консольное приложение (условно назовём его MiniBoard): у нас есть задачи (Task) и колонки (Column) в стиле “Todo / Doing / Done”. Колонка хранит список задач, а задача… внезапно тоже хочет знать, в какой колонке она лежит, чтобы быстро печатать статус.

На уровне “хочется сделать удобно” рука тянется написать так: “пусть Column хранит shared_ptr<Task>, а Task хранит shared_ptr<Column>”. Звучит логично: и там владеем, и тут владеем. Вот только это и есть готовый цикл владения.

Сделаем аккуратно, по шагам. Сначала объявления типов (обратите внимание на forward declaration — мы уже умеем “предварительно объявлять” тип, чтобы разорвать зависимость по имени):

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

struct Column; // предварительное объявление

struct Task {
    int id{};
    std::string title;
    std::shared_ptr<Column> column; // "назад" (и вот тут начинается риск)
    ~Task() { std::cout << "~Task(" << id << ")\n"; }
};

Теперь колонка, которая владеет задачами:

struct Column {
    std::string name;
    std::vector<std::shared_ptr<Task>> tasks; // "вперёд" — владеем задачами
    ~Column() { std::cout << "~Column(" << name << ")\n"; }
};

А теперь — самое интересное: создаём и связываем.

int main() {
    auto todo = std::make_shared<Column>();
    todo->name = "Todo";

    auto t = std::make_shared<Task>();
    t->id = 1;
    t->title = "Write C++ code";

    todo->tasks.push_back(t);
    t->column = todo; // цикл: Column -> Task -> Column

    std::cout << "Leaving main...\n"; // Leaving main...
}

Запускаете — и видите: “Leaving main...” напечаталось, а ~Task(1) и ~Column(Todo)нет. То есть с точки зрения нашей “сигнализации” объекты не уничтожились. Утечка “без delete” случилась, потому что никто не обязан писать delete, но никто и не обязан “разорвать круг” вместо вас.

4. Что происходит со счётчиками и почему это не магия

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

В момент после создания todo у колонки один владелец — переменная todo.

В момент после создания t у задачи один владелец — переменная t.

Когда мы делаем todo->tasks.push_back(t), мы копируем shared_ptr в вектор. Это добавляет ещё одного владельца задачи: теперь на Task ссылается и переменная t, и элемент вектора todo->tasks[0].

Когда мы делаем t->column = todo, мы копируем shared_ptr колонки в задачу. Это добавляет ещё одного владельца колонки: теперь на Column ссылается и переменная todo, и поле t->column.

А дальше наступает конец main(). Локальные переменные todo и t уничтожаются, и счётчики уменьшаются. Но полностью до нуля они не доходят, потому что колонку всё ещё держит задача через t->column, а задачу всё ещё держит колонка через tasks.

Получается закрытый круг.

Да, можно вывести use_count() и посмотреть числа, но помним: use_count() — это скорее “фонарик для отладки”, а не способ построить надёжную бизнес-логику. Идея о том, что у use_count() не самые сильные гарантии в общем случае, всплывает даже в обсуждениях по стандартной библиотеке, так что относитесь к нему как к диагностике.

5. Формы циклов и разница между владением и навигацией

Самые частые циклы: “туда‑обратно” и “соседи”

Когда вы пишете прикладной код, циклы редко выглядят как “я специально сделал цикл”. Обычно они выглядят как “ну логично же: объект A знает объект B, а объект B знает объект A”. Особенно часто это случается в двух местах: в двусвязных структурах и в “родитель–ребёнок” моделях.

Вот классический пример с “соседями” — двусвязный список. Он не требует даже вектора: всего два указателя.

#include <memory>

struct Node {
    int value{};
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev; // если это shared_ptr — цикл почти гарантирован
};

Если вы создадите два узла и сделаете a->next = b, а b->prev = a, то у вас уже “кольцо владения” на двоих. И снова деструкторы не вызовутся, даже если вы вышли из main.

Похожая история с “родитель–ребёнок”. Например, папка хранит файлы, а файл хранит ссылку на папку, чтобы быстро печатать полный путь. Если обе связи владеющие — вы собрали цикл из реальной жизни, почти не напрягаясь.

“Ссылка для навигации” vs “ссылка для владения”

Сейчас будет самая полезная (и самая взрослая) мысль сегодняшней лекции. В реальном проектировании надо различать два разных смысла связи.

Связь для владения временем жизни отвечает на вопрос: “кто обязан держать объект живым?”. В нашем дне это обычно shared_ptr.

Связь для навигации отвечает на вопрос: “как мне найти связанный объект, если он ещё жив?”. И вот тут не всегда нужно владение. Иногда нужно просто “посмотреть”.

Когда мы делали Task::column, мы на самом деле хотели навигацию: задача “хочет знать”, где она лежит. Но мы дали ей владение: задача стала совладельцем колонки. И это породило цикл.

Отсюда очень практическое правило: если связь “назад” или “в сторону” нужна только для удобного доступа, она часто должна быть невладеющей. Как именно сделать её невладеющей — это уже следующая лекция, где мы познакомимся с std::weak_ptr, который как раз и задуман как “ссылка без владения”.

Сам факт существования weak_ptr в стандартной библиотеке и его отдельная эволюция — не случайность: это инструмент, которым чинят такие архитектурные узлы.

6. Почему “разорвать reset-ом” — временная заплатка

Когда вы впервые ловите цикл владения, хочется сделать “по‑быстрому”: “ну ладно, я перед выходом из программы вызову reset(), и всё”. Иногда это действительно сработает… но именно это и делает подход опасным: он работает ровно до первого случая, когда код выходит не там, где вы ожидали.

Например, вы можете вручную разорвать цикл так:

// ... после создания и связывания:
t->column.reset();     // убрали владение колонкой из задачи
todo->tasks.clear();   // убрали владение задачей из колонки

После этого счётчики действительно могут упасть до нуля, и деструкторы вызовутся. Но цена вопроса такая: вы должны помнить, что перед “завершением” надо сделать “ритуал уборки”, причём в правильном порядке, причём во всех ветках кода. Это быстро превращается в игру “угадай, где я забыл убрать”.

В хорошем дизайне цикл чинится не “танцами с reset()”, а типами и контрактами: где владение, а где наблюдение. И да, именно поэтому следующий шаг курса — weak_ptr.

7. Типичные ошибки при shared_ptr и циклах владения

Ошибка №1: “Если я не писал delete, утечки быть не может”.
Это очень человеческая ловушка: delete ассоциируется с памятью, и кажется, что если его нет, то и проблем нет. Но цикл владения — это ситуация, где память не освобождается не из-за забывчивости, а из-за логики счётчиков: они честно не доходят до 0.

Ошибка №2: Делать обе стороны связи владеющими “на всякий случай”.
Психологически хочется “подстелить соломку”: пусть всё будет shared_ptr, тогда ничего не упадёт. В реальности вы подстилаете не соломку, а кольцо: A держит B, B держит A, и оба живут вечно. “На всякий случай” — это главный поставщик циклов в природе.

Ошибка №3: Лечить цикл reset()-ами в случайных местах.
Иногда цикл чинят так: “где-то в деструкторе/очистке я сделаю reset()”. Это превращает код в минное поле: очистка начинает зависеть от порядка, от того, кто первый умер, и от того, какие ссылки ещё живы. Правильное лечение — изменить модель владения, а не добавлять магические “обнуления”.

Ошибка №4: Пытаться построить корректность программы на use_count().
use_count() — полезная лампочка для понимания “почему оно не удалилось”, но плохой фундамент для решений “можно ли удалять”, “кто главный владелец” и так далее. У этого метода есть ограничения по гарантиям, поэтому воспринимайте его как диагностический вывод в лог, а не как условие в if.

Ошибка №5: Не видеть цикл в коде, потому что “это же просто поля”.
Цикл владения часто не выглядит страшно: всего два поля shared_ptr в двух структурах. Но именно это и должно настораживать: если у вас “туда” shared_ptr и “обратно” shared_ptr, остановитесь и спросите себя: “а правда ли обе стороны должны владеть временем жизни?”

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