shared_ptr и weak_ptr
Вопрос с собеседования: в этом коде нет ни одного явного delete, но память всё равно утекает. Почему?
$$ $$
#include <memory>
#include <iostream>
struct Node {
std::string name;
std::shared_ptr<Node> next; // ссылка на соседа
Node(std::string n) : name(std::move(n)) {
std::cout << "Создан узел " << name << "\n";
}
~Node() {
std::cout << "Уничтожен узел " << name << "\n";
}
};
int main() {
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->next = a; // b и a теперь ссылаются друг на друга
}
// main закончился — a и b выходят из области видимостиВывод:
Создан узел A
Создан узел BЗаметь: строк «Уничтожен узел A» и «Уничтожен узел B» в выводе нет вообще. Узлы создались, а деструкторы не вызвались, хотя main() уже закончился и локальные переменные a и b формально вышли из области видимости. Это классическая утечка памяти из-за циклической ссылки — одна из самых частых ловушек про shared_ptr на собеседовании.
Чтобы понять, почему так происходит, нужно знать, как устроен shared_ptr изнутри. В отличие от unique_ptr, который просто хранит сырой указатель, shared_ptr хранит два указателя: один — на сам объект, второй — на служебный блок управления (control block) с счётчиком ссылок (reference count). Когда ты копируешь shared_ptr, счётчик увеличивается на единицу. Когда shared_ptr умирает (выходит из области видимости), счётчик уменьшается на единицу. И только когда счётчик доходит до нуля — то есть последний владелец исчез — вызывается delete на самом объекте. Это и есть «разделяемое владение»: объектом может владеть сколько угодно shared_ptr одновременно, и он живёт ровно до тех пор, пока жив хотя бы один из них.
Теперь смотрим на пример. У узла A счётчик ссылок равен 2: одна ссылка — от переменной a в main, вторая — от поля next внутри узла B. Аналогично у узла B счётчик тоже равен 2: одна ссылка от переменной b, вторая — от поля next внутри узла A. Когда main() заканчивается, переменные a и b умирают — это снижает оба счётчика с 2 до 1. Но не до нуля! Потому что узлы всё ещё держат друг друга через свои поля next. А раз счётчик не дошёл до нуля, delete не вызывается никогда — образуется циклическая зависимость, где A жив, потому что на него ссылается B, а B жив, потому что на него ссылается A. Это утечка памяти, которую даже valgrind не всегда покажет очевидным образом, потому что формально на объекты «есть ссылки» — просто ссылки эти уже никому не нужны и до них никак не добраться извне.
Решение — std::weak_ptr. Это указатель-наблюдатель: он видит объект, но не увеличивает счётчик ссылок shared_ptr. Если заменить одно из двух полей next на weak_ptr — например, «ссылка назад» в связном списке или дереве обычно должна быть слабой, — цикл разрывается:
$$ $$
struct Node {
std::string name;
std::shared_ptr<Node> next; // владеющая ссылка вперёд
std::weak_ptr<Node> prev; // наблюдающая ссылка назад
};
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->prev = a; // weak_ptr — счётчик A не растётТеперь у узла A счётчик ссылок равен 1 (только переменная a), а у узла B — тоже 1 плюс сильная ссылка из a->next, итого 2, но когда переменная b умирает, у B остаётся только сильная ссылка от A. Когда main() заканчивается и обе локальные переменные умирают: сначала счётчик b падает до 1 (держит его a->next), затем счётчик a падает до 0 — a уничтожается, вместе с ним обнуляется a->next, и это роняет счётчик b до 0 — b тоже уничтожается. Цепочка разматывается корректно, оба деструктора вызовутся.
Важный нюанс, который тоже спрашивают: через weak_ptr нельзя напрямую разыменовать объект — сначала нужно вызвать метод lock(), который возвращает временный shared_ptr (или пустой, если объект уже удалён). Это защищает от обращения к уже освобождённой памяти — weak_ptr сам знает, жив ли ещё объект, на который он смотрит, и честно скажет «нет», а не отдаст висячий указатель.
Практическое правило с собеседований: если в структуре данных есть двусторонние связи (родитель↔ребёнок, узел↔сосед в графе), одна сторона связи должна быть shared_ptr (владение), а обратная — weak_ptr (просто знание, что сосед существует). Это стандартный паттерн, который убирает циклы утечек ещё на этапе проектирования структуры.