1. Зачем нужен weak_ptr
Когда впервые видишь std::shared_ptr, возникает ощущение, что это универсальный ответ на вопрос «как жить без delete». И правда: общий счётчик владельцев, автоматическое удаление объекта, красота. Но у этой красоты есть цена: когда владение и просто ссылка “для навигации” смешиваются, можно случайно построить структуру данных, которая никогда не освободится.
Представьте обычную жизненную ситуацию: у нас есть сущность “Проект” и сущность “Задача”. Проект “содержит” задачи — логично. А задача “знает”, к какому проекту относится — тоже логично, потому что хочется печатать “(проект: Учёба)” или быстро перейти к настройкам проекта.
Если обе стороны «логично» реализовать через shared_ptr, получается замкнутый круг: проект держит задачи, задачи держат проект, счётчики ссылок не падают до нуля, и деструкторы не вызываются. То есть delete вы нигде не писали… а утечка есть.
Именно поэтому нам нужен инструмент «ссылка без владения». И сегодня это — std::weak_ptr.
weak_ptr как наблюдатель: ссылка без владения
std::weak_ptr<T> — это умный указатель, который “смотрит” на объект, управляемый std::shared_ptr<T>, но не является владельцем. Его ключевая идея звучит почти философски: “я знаю, где объект, но не обещаю, что он будет жить”.
Технически weak_ptr связан с тем же самым контрольным блоком, что и shared_ptr (тот самый блок, где лежат счётчики). Для практики важнее всего два правила.
Первое правило: weak_ptr не увеличивает число владельцев, значит не продлевает жизнь объекта.
Второе правило: чтобы безопасно “дотронуться” до объекта, weak_ptr нужно превратить во временный shared_ptr через lock(). Если объект ещё жив — получим непустой shared_ptr. Если уже уничтожен — получим пустой.
Чтобы не держать всё в голове в виде абстракций, можно представить так:
flowchart LR
A["shared_ptr<Project>"] -->|владеет| P["Project object"]
P -->|владеет| T["shared_ptr<Task> tasks"]
T -->|наблюдает| W["weak_ptr<Project> project"]
Здесь только один тип стрелки “владеет” (это shared_ptr), а обратная связь сделана “наблюдением” (это weak_ptr). Цикла владения нет — и это именно то, чего мы добиваемся.
2. Создание и безопасный доступ
Как создать weak_ptr и что такое пустой weak_ptr
В начале работы weak_ptr часто выглядит странно: “а где *w? а где w->field?” — и это хороший знак. Значит, вы пока не даёте себе случайно разыменовать то, чего уже может не существовать.
Обычно weak_ptr получается из shared_ptr. Это похоже на «взять визитку объекта», но не «получить ключи от квартиры».
#include <memory>
#include <iostream>
int main() {
auto p = std::make_shared<int>(10);
std::weak_ptr<int> w = p;
std::cout << "ok\n"; // ok
}
weak_ptr может быть и пустым (в состоянии “ни на что не смотрю”). Это нормальное состояние — примерно как nullptr у обычного указателя, только культурнее.
#include <memory>
#include <iostream>
int main() {
std::weak_ptr<int> w; // пустой
auto locked = w.lock();
std::cout << std::boolalpha << (locked == nullptr) << "\n"; // true
}
Важно понимать: “пустота” weak_ptr — не какая-то редкая ошибка, а нормальная часть контракта.
lock() — безопасный доступ: проверил и зафиксировал
Самая важная привычка при работе с weak_ptr — никогда не пытаться использовать объект напрямую, а всегда делать “lock + проверка”.
Смысл lock() в том, что он создаёт временный shared_ptr (то есть временного владельца). Пока этот shared_ptr живёт в вашей переменной, объект гарантированно существует. И это очень приятное чувство: вы как будто “взяли объект за руку” на время работы.
Мини-паттерн выглядит так:
#include <memory>
void use_if_alive(const std::weak_ptr<int>& w) {
if (auto p = w.lock()) {
// объект точно жив, пока p не уничтожен
(void)*p;
} else {
// объекта уже нет
}
}
Обратите внимание на стиль: if (auto p = w.lock()) — это не просто красиво, это ещё и защищает от классической ошибки “проверил отдельно — использовал отдельно”. Если вы проверили в одном месте, а используете позже, между этими действиями объект мог умереть (особенно в более сложных программах). А так у вас всё в одном блоке.
Ещё одна важная привычка: залочить один раз и дальше использовать только залоченную переменную. Не делайте пять lock() подряд — это как пять раз спросить паспорт у одного и того же человека, потому что вам кажется, что он мог поменяться за секунду.
expired() и use_count(): смотреть можно, полагаться нельзя
У weak_ptr есть метод expired(): он отвечает на вопрос “объект уже уничтожен?”. На практике он полезен как лёгкая подсказка, но в реальном коде доступ всё равно должен идти через lock().
Почему? Потому что expired() — это просто проверка “на сейчас”. Если сразу после неё вы попытаетесь получить доступ к объекту без lock(), вы снова возвращаетесь в гонку: “между проверкой и использованием могло измениться состояние”.
В учебных программах (и в отладке) expired() можно использовать как “индикатор”. Но если вам нужен объект — только lock().
А ещё у weak_ptr можно спросить use_count() (сколько сейчас сильных владельцев через shared_ptr). Но тут действует то же правило, что и у shared_ptr::use_count(): это диагностический инструмент, не основа корректности.
Правильная модель такая: хотите “если объект жив — поработай” → используйте lock().
3. Разрыв цикла владения на примере TaskBook
С этого места давайте сделаем то, что программисты любят больше всего: создадим себе проблему и героически её решим.
Представим, что у нас есть маленькое консольное приложение TaskBook: оно хранит проекты и задачи. Мы не будем делать “полный менеджер задач”, нам сейчас важна только модель данных и владение.
Антипример: цикл владения “Проект ↔ Задача”
Начнём с неправильной версии, чтобы почувствовать боль. Проект владеет задачами, и каждая задача владеет проектом.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Project;
struct Task {
std::string title;
std::shared_ptr<Project> project; // плохо: владеем "назад"
~Task() { std::cout << "~Task: " << title << "\n"; }
};
struct Project {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // владеем задачами
~Project() { std::cout << "~Project: " << name << "\n"; }
};
Теперь соберём цикл:
#include <memory>
int main() {
auto pr = std::make_shared<Project>();
pr->name = "C++ course";
auto t = std::make_shared<Task>();
t->title = "Read about weak_ptr";
pr->tasks.push_back(t);
t->project = pr; // цикл владения
}
При выходе из main вы можете ожидать увидеть ~Task... и ~Project.... Но из‑за цикла владения деструкторы часто не вызываются: проект держит задачу, задача держит проект. Счётчики не становятся нулём.
Исправление: задача ссылается, но не владеет проектом
Логика предметной области очень простая: проект владеет задачей, а задача просто “знает, где её проект”. Это не владение — это навигация. Значит, назад нужен weak_ptr.
#include <iostream>
#include <memory>
#include <string>
#include <vector>
struct Project;
struct Task {
std::string title;
std::weak_ptr<Project> project; // хорошо: наблюдаем
~Task() { std::cout << "~Task: " << title << "\n"; }
};
struct Project {
std::string name;
std::vector<std::shared_ptr<Task>> tasks; // владеем задачами
~Project() { std::cout << "~Project: " << name << "\n"; }
};
Создание связи почти не меняется — просто присваиваем weak_ptr:
#include <memory>
int main() {
auto pr = std::make_shared<Project>();
pr->name = "C++ course";
auto t = std::make_shared<Task>();
t->title = "Read about weak_ptr";
pr->tasks.push_back(t);
t->project = pr; // теперь это weak_ptr, цикла нет
}
Теперь при выходе из main деструкторы вызываются естественно: сначала умирает pr (последний shared_ptr на проект), это уничтожает проект, а проект уничтожает вектор задач (точнее, уменьшает счётчики shared_ptr в векторе), задачи теряют последних владельцев и тоже уничтожаются. Никакой магии, просто правильная модель владения.
Как безопасно печатать “задача принадлежит проекту X”
Раз у Task::project теперь weak_ptr, мы не можем сделать task.project->name. И это правильно: мы не имеем права вести себя так, будто проект бессмертен.
Пишем аккуратную функцию печати:
#include <iostream>
#include <memory>
#include <string>
void print_task(const Task& t) {
if (auto pr = t.project.lock()) {
std::cout << t.title << " (project: " << pr->name << ")\n";
} else {
std::cout << t.title << " (project: deleted)\n";
}
}
Если проект жив — печатаем имя. Если нет — печатаем, что проект удалён. И это, кстати, нормальная UI-логика даже для настоящих приложений: “ссылка устарела”.
4. Типичные ошибки при работе с weak_ptr
Ошибка №1: воспринимать weak_ptr как “почти shared_ptr”, который можно разыменовывать.
Иногда рука тянется сделать w->field или *w, потому что “ну он же указатель”. Но weak_ptr специально устроен так, чтобы вы не могли напрямую разыменовать потенциально несуществующий объект. Правильный путь — только lock() и работа через временный shared_ptr.
Ошибка №2: делать if (!w.expired()) и потом использовать объект без lock().
Это выглядит логично, но на самом деле это паттерн “проверил дверь — отвернулся — пошёл — дверь уже закрыли”. Между проверкой и использованием объект может исчезнуть. Единственный безопасный способ получить доступ — auto p = w.lock(); и проверка if (p).
Ошибка №3: вызывать lock() много раз в одном выражении или в разных местах, а потом использовать разные результаты.
Такой код сложнее читать и он легко приводит к “не тем” проверкам. Лучше залочить один раз в локальную переменную и работать только с ней. Это ещё и улучшает читаемость: сразу видно, где у вас начинается участок “объект точно жив”.
Ошибка №4: лечить цикл владения “точечным reset где-то” вместо смены модели.
Иногда пытаются “вручную” разорвать цикл: где-то в коде вызвать reset() у одного из shared_ptr и надеяться, что всё рассосётся. Это нестабильно: вы боретесь с симптомом, а не с причиной. Правильное лечение — изменить тип связи: где владение, там shared_ptr; где навигация/наблюдение, там weak_ptr.
Ошибка №5: превращать weak_ptr в “обязательную ссылку” без обработки сценария “объекта уже нет”.
Если вы храните weak_ptr, вы по контракту признаёте: объект может быть уничтожен. Значит, любой код, который работает с weak_ptr, обязан иметь ветку “объекта нет” — пусть даже это будет “ничего не делаем” или “печатаем (deleted)”. Если вам нужен объект, который обязан жить, значит кто-то должен владеть им через shared_ptr (или через другой явный механизм владения), а не просто наблюдать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ