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, остановитесь и спросите себя: “а правда ли обе стороны должны владеть временем жизни?”
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ