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() один раз і далі працювати лише з отриманою змінною. Не робіть пʼять 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++";
auto t = std::make_shared<Task>();
t->title = "Прочитати про 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++";
auto t = std::make_shared<Task>();
t->title = "Прочитати про weak_ptr";
pr->tasks.push_back(t);
t->project = pr; // тепер це weak_ptr, циклу немає
}
Тепер під час виходу з main деструктори викликаються природно: щойно зникає останній 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 << " (проєкт: " << pr->name << ")\n";
} else {
std::cout << t.title << " (проєкт: видалено)\n";
}
}
Якщо проєкт живий — друкуємо імʼя. Якщо ні — повідомляємо, що проєкт видалено. І це, до речі, цілком нормальна логіка інтерфейсу навіть для справжніх застосунків: «посилання застаріло».
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() багато разів в одному виразі або в різних місцях, а потім використовувати різні результати.
Такий код складніше читати, і він легко призводить до хибних перевірок. Краще викликати lock() один раз, зберегти результат у локальну змінну й далі працювати лише з нею. Це ще й поліпшує читабельність: одразу видно, де у вас починається ділянка «обʼєкт точно живий».
Помилка №4: лікувати цикл володіння «точковим reset десь» замість зміни моделі.
Іноді намагаються «вручну» розірвати цикл: десь у коді викликати reset() в одного зі shared_ptr і сподіватися, що все якось владнається. Це нестабільно: ви боретеся із симптомом, а не з причиною. Правильне рішення — змінити тип звʼязку: де володіння, там shared_ptr; де навігація або спостереження, там weak_ptr.
Помилка №5: перетворювати weak_ptr на «обовʼязкове посилання» без обробки сценарію «обʼєкта вже немає».
Якщо ви зберігаєте weak_ptr, то за контрактом визнаєте: обʼєкт може бути знищено. Отже, будь-який код, який працює з weak_ptr, зобовʼязаний мати гілку «обʼєкта немає» — хай навіть це буде «нічого не робимо» або «друкуємо (видалено)». Якщо ж вам потрібен обʼєкт, який має обовʼязково жити, то хтось повинен володіти ним через shared_ptr (або через інший явний механізм володіння), а не просто спостерігати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ