JavaRush /Курси /C++ SELF /std::weak_ptr — спостерігач і розрив циклів

std::weak_ptr — спостерігач і розрив циклів

C++ SELF
Рівень 44 , Лекція 1
Відкрита

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 (або через інший явний механізм володіння), а не просто спостерігати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ