JavaRush /Курси /C++ SELF /std::unique_ptr і

std::unique_ptr і std::make_unique

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

1. T* — адреса, але не власник

Коли ви вперше побачили вказівники, вони, мабуть, здавалися суперсилою: можна зберігати адресу, передавати її у функції, перевіряти на nullptr. Але у вказівника є неприємна особливість: він аж надто чесний. Він каже лише: «ось адреса», але не пояснює, «хто має звільняти памʼять» і «коли це потрібно зробити». Без такого контракту володіння перетворюється на гру «вгадай, де delete».

Уявіть, що T* — це папірець з адресою квартири. Сам по собі він не відповідає на запитання: хто власник квартири, хто платить за оренду і чи можна там узагалі жити. Так само int* p не каже, чи є p власником памʼяті й чи має він робити delete, чи це просто спосіб «показати пальцем» на обʼєкт, яким володіє хтось інший.

Подивімося на мінімальний приклад проблеми. Це антиприклад: у реальному коді так краще не робити.

#include <iostream>

int main() {
    int* p = new int(42);
    std::cout << *p << '\n'; // 42

    // Ой, а де delete?
}

Якщо забути delete, отримаєте витік памʼяті. Якщо написати delete, а потім хтось іще також виконає delete за тією самою адресою, буде double-free. Якщо видалити обʼєкт, а потім випадково використати *p, отримаєте use-after-free. Це не «рідкісні помилки», а цілком буденна річ, якщо писати код із ручним керуванням памʼяттю без суворої дисципліни.

2. Що таке std::unique_ptr: RAII і один власник

Тепер ми підходимо до однієї з найрятівніших ідей сучасного C++: замість того щоб носити по коду голу адресу й сподіватися на уважність людей, ми починаємо зберігати ресурс у спеціальному обʼєкті-власнику. Такий обʼєкт знає, що володіє ресурсом, і зобовʼязується звільнити його у своєму деструкторі. Це і є RAII у дії: «Resource Acquisition Is Initialization».

std::unique_ptr<T> — це розумний вказівник, який володіє обʼєктом типу T і звільняє його автоматично, коли сам unique_ptr знищується. При цьому слово unique («унікальний») означає головне: власник лише один. Тобто в кожен момент часу динамічний обʼєкт має рівно один unique_ptr, який відповідає за звільнення.

Важливо зафіксувати два нормальні стани unique_ptr. Він або справді володіє обʼєктом, тобто всередині зберігається непорожня адреса, або є порожнім, тобто всередині nullptr. Порожній стан — не «помилка», а частина моделі: іноді обʼєкта ще немає, іноді він необовʼязковий, а іноді його вже звільнено.

Приємно й те, що стандартна бібліотека давно й послідовно розвиває цей підхід, уточнюючи гарантії та поведінку unique_ptr у різних редакціях стандарту.

3. Створення через std::make_unique

Перший практичний крок із unique_ptr зазвичай починається з двох речей: підʼєднати заголовок <memory> і перестати вручну писати new там, де без нього можна обійтися. Так, звучить це як «сектантське гасло», але в цій секті замість свічок — просто безпечніший код.

Правильний, ідіоматичний спосіб створити unique_ptr — це std::make_unique<T>(...). Він створює обʼєкт і відразу пакує його в unique_ptr. Історично поява й закріплення make_unique стали важливим кроком у бік безпечнішого й одноманітнішого коду.

Найпростіший приклад:

#include <iostream>
#include <memory>

int main() {
    auto p = std::make_unique<int>(42);
    std::cout << *p << '\n'; // 42
}

Зверніть увагу на цю невелику «магію спокою»: delete ніде не написано, але памʼять буде звільнено автоматично під час виходу з main, тому що p — локальна змінна, а її деструктор викликається, коли завершується область видимості.

Трохи живіший приклад зі структурою:

#include <iostream>
#include <memory>
#include <string>

struct User {
    std::string name;
    int age{};
};

int main() {
    auto u = std::make_unique<User>(User{"Alice", 30});
    std::cout << u->name << " " << u->age << '\n'; // Alice 30
}

Тут ми вже бачимо ще одну важливу деталь: доступ до полів відбувається через ->, як у звичайного вказівника. unique_ptr відчувається як «майже вказівник», але з важливим бонусом: «я ще й володію».

4. Доступ, порожнеча й модель володіння

Доступ до обʼєкта: *p і p->field

Після створення unique_ptr хочеться робити з ним те саме, що ви робили з T*: розіменовувати, читати поля, змінювати їх, передавати далі. І тут unique_ptr поводиться цілком очікувано: *p дає доступ до обʼєкта, а p->field — до його полів і методів.

Це зручно, бо вам не доводиться вчити «новий синтаксис доступу». У голові лишається звична модель: «там лежить адреса, а за нею — обʼєкт». Просто тепер ця адреса — не просто адреса, а адреса в надійному пакуванні з автоматичним звільненням.

Мініприклад зі зміною значення:

#include <iostream>
#include <memory>

int main() {
    auto p = std::make_unique<int>(10);
    *p += 5;

    std::cout << *p << '\n'; // 15
}

І приклад із полями структури:

#include <iostream>
#include <memory>
#include <string>

struct Note {
    std::string text;
};

int main() {
    auto n = std::make_unique<Note>(Note{"привіт"});
    n->text += " світе";

    std::cout << n->text << '\n'; // привіт світе
}

Поки що ми навмисно не обговорюємо перенесення володіння і те, «що відбувається під час копіювання», бо це окрема велика тема наступної лекції. Тут наша мета простіша: навчитися створювати й використовувати unique_ptr у базовому режимі, без ручного звільнення.

Порожній unique_ptr і перевірка if (p)

Життя рідко влаштоване так, що обʼєкт «є завжди». Іноді він зʼявляється пізніше, іноді є необовʼязковим, а іноді його створення пропускає логіка програми. Саме тому unique_ptr уміє бути порожнім, тобто зберігати всередині nullptr.

На щастя, перевіряється це просто. unique_ptr можна використовувати в умові: if (p). Це читається майже як звичайна людська фраза: «якщо вказівник не порожній».

Подивімося на базовий сценарій: спочатку порожньо, потім створюємо обʼєкт.

#include <iostream>
#include <memory>

int main() {
    std::unique_ptr<int> p; // порожній

    if (!p) {
        std::cout << "порожньо\n"; // порожньо
    }

    p = std::make_unique<int>(7);

    if (p) {
        std::cout << *p << '\n'; // 7
    }
}

Тут важливо звикнути до дисципліни: якщо порожній стан можливий, то перед *p або p->... варто зробити перевірку. Це як ремінь безпеки: так, іноді поїздка коротка, але одного дня ця звичка врятує вам нервову систему.

Адреса проти власника: мінісхема

Корисно на мить зупинитися й «намалювати в голові», що саме змінилося. До unique_ptr ви могли зберігати адресу, але володіння лишалося десь у повітрі. З unique_ptr володіння стає частиною типу, тобто частиною контракту, який видно вже з оголошення змінної.

Ось невелика таблиця для порівняння:

Інструмент Що зберігає Хто звільняє ресурс Типова проблема
T*
адресу «хтось десь» витоки, double-free, use-after-free
std::unique_ptr<T>
адресу + контракт володіння сам unique_ptr у деструкторі потрібно памʼятати про порожній стан і не поєднувати це з ручним delete

І проста блок-схема володіння:

flowchart TD
    A[Створили ресурс] --> B{Хто власник?}
    B -->|T*| C[Із типу це незрозуміло]
    C --> D[Ризик: забули delete / виконали delete двічі]
    B -->|unique_ptr| E[Власник відомий]
    E --> F[Вихід з області видимості -> деструктор -> звільнення]

У цьому місці зазвичай зʼявляється відчуття: «А що, так можна було?». Так, можна. І це один із рідкісних моментів у програмуванні, коли відповідь «так» справді спрощує життя, а не додає новий фреймворк на 600 залежностей.

5. Практичний приклад: TaskBox з необовʼязковими деталями

Щоб unique_ptr не виглядав як «розумний вказівник заради розумного вказівника», давайте вбудуємо його в невеликий консольний застосунок. Уявімо, що в нас є список задач. Сама задача є завжди, а «деталі» — наприклад, довгий опис — не завжди. Ми могли б зберігати порожній рядок, але це не дуже точно передає сенс: порожній рядок — це теж дані, а «відсутність деталей» — це відсутність обʼєкта.

Зробімо так: у Task буде поле details, але не std::string, а std::unique_ptr<TaskDetails>. Тоді nullptr означатиме: «деталей немає».

Моделі даних

Спочатку опишемо структури. Цей фрагмент коду невеликий, але саме він задає основу всьому прикладу.

#include <memory>
#include <string>

struct TaskDetails {
    std::string description;
};

struct Task {
    int id{};
    std::string title;
    std::unique_ptr<TaskDetails> details; // або є, або nullptr
};

Зверніть увагу: ми поки що не створюємо деталі, а лише кажемо, що вони в принципі можуть бути. Уже це робить модель чеснішою.

Створення задач: з деталями й без

Тепер додамо дві невеликі функції-фабрики. Вони повертають готовий Task — звичайний обʼєкт, а всередині, якщо потрібно, створюють TaskDetails через make_unique.

#include <memory>
#include <string>

Task makeTask(int id, std::string title) {
    Task t;
    t.id = id;
    t.title = std::move(title);
    return t;
}

Task makeTaskWithDetails(int id, std::string title, std::string description) {
    Task t;
    t.id = id;
    t.title = std::move(title);
    t.details = std::make_unique<TaskDetails>(TaskDetails{std::move(description)});
    return t;
}

Тут ми використовуємо std::move для рядків як оптимізацію: щоб не копіювати зайвого. Якщо для вас це поки що звучить трохи туманно — це нормально. Рядки просто ніби «переміщуються» всередину структури. Глибшу механіку перенесення ми розберемо окремо, а ідея «не дублювати великі рядки» інтуїтивно зрозуміла вже зараз.

Друк задачі з перевіркою details

Тепер напишемо виведення. Тут важливо акуратно перевіряти вказівник на порожнечу.

#include <iostream>

void printTask(const Task& t) {
    std::cout << "#" << t.id << ": " << t.title;

    if (t.details) {
        std::cout << " (" << t.details->description << ")";
    }

    std::cout << '\n';
}

Сенс тут читається майже як звичайна фраза: «якщо деталі є — додрукуй».

Збираємо main: кілька задач у std::vector

Тепер зберемо мінімальний main. Інтерактивне введення поки що не додаємо, щоб не відволікатися від теми володіння. Просто створимо кілька задач і виведемо їх.

#include <iostream>
#include <memory>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;

    tasks.push_back(makeTask(1, "Купити молоко"));
    tasks.push_back(makeTaskWithDetails(2, "Вивчати C++", "основи unique_ptr"));

    for (const Task& t : tasks) {
        printTask(t);
    }
}

Очікуваний вивід:

#1: Купити молоко
#2: Вивчати C++ (основи unique_ptr)

Зауважте важливу річ: ми ніде не пишемо delete. Водночас усередині задач можуть бути динамічно створені TaskDetails. Їх буде звільнено автоматично, коли знищиться обʼєкт Task, а він, своєю чергою, знищиться, коли vector очиститься або вийде з області видимості. Це і є RAII у реальному житті — без гасел.

6. Коли спрацьовує деструктор: перевіряємо автоматичність

У навчальних прикладах іноді важко повірити: «воно справді саме?». Тому зробімо дуже просту діагностику: додамо деструктор у TaskDetails, який друкує повідомлення. Так, у реальних проєктах із деструктора зазвичай нічого не друкують, але для навчання це чудовий «ліхтарик».

#include <iostream>
#include <memory>
#include <string>

struct TaskDetails {
    std::string description;

    ~TaskDetails() {
        std::cout << "TaskDetails знищено: " << description << '\n';
    }
};

int main() {
    {
        auto d = std::make_unique<TaskDetails>(TaskDetails{"тимчасова інформація"});
        std::cout << "Усередині блоку\n"; // Усередині блоку
    }
    std::cout << "Поза блоком\n"; // Поза блоком
}

Очікуваний вивід буде приблизно таким:

Усередині блоку
TaskDetails знищено: тимчасова інформація
Поза блоком

Тобто знищення відбулося рівно на межі блоку { ... }. І це ключовий ефект: ви починаєте мислити не в категоріях «де б мені написати delete», а в категоріях «де закінчується область життя власника». Це перемикає мислення в надійніший режим.

7. Типові помилки під час знайомства з unique_ptr

Помилка № 1: розіменування порожнього unique_ptr.
Оскільки unique_ptr може бути в стані nullptr, вирази *p і p->field без перевірки іноді можуть завершитися падінням застосунку. Це часто трапляється після рефакторингу: раніше обʼєкт завжди створювався, потім додали умову — і раптом вказівник став порожнім. Якщо порожній стан можливий за змістом, перевірка if (p) має стати звичкою.

Помилка № 2: спроба «допомогти» unique_ptr і написати delete вручну.
Найпідступніша помилка новачка — побачити, що всередині ніби лежить «звичайний вказівник», і захотіти «зробити як краще», звільнивши памʼять вручну. Але якщо обʼєкт уже під керуванням unique_ptr, звільнення виконує тільки він. Ручний delete призводить до подвійного звільнення: спочатку ви видалили обʼєкт, а потім unique_ptr видалить його ще раз під час знищення.

Помилка № 3: створення unique_ptr через new у прикладному коді за старою звичкою.
Іноді пишуть щось на кшталт std::unique_ptr<int> p(new int(5));. Технічно це може компілюватися, але з погляду стилю та безпеки такий підхід легко приводить до складніших помилок, особливо коли у виразі зʼявляється кілька ресурсів. Базова дисципліна проста: створюємо через std::make_unique<T>(...), бо це читабельніше й легше перевіряти очима.

Помилка № 4: сприймати unique_ptr як «просто вказівник», забуваючи про контракт володіння.
Так, за синтаксисом він схожий на вказівник, але за змістом ближчий до «контейнера-власника». Якщо ви починаєте передавати його по коду без чіткого розуміння, «хто володіє», дуже швидко виникає плутанина: де обʼєкт живе, коли він знищиться, чому раптом став nullptr. На цьому етапі важливо тримати в голові просту мантру: unique_ptr — це про володіння, а не про «зручний доступ до памʼяті».

Помилка № 5: зберігати «десь поруч» окремий прапорець наявності замість того, щоб використовувати порожній стан.
Новачки іноді роблять так: bool hasDetails; TaskDetails* details; а далі вручну синхронізують ці два поля. Майже гарантовано з часом вони розсинхронізуються. unique_ptr уже містить у собі ідею «є/немає» — через nullptr, тому окремий bool найчастіше просто не потрібен: наявність виражається самим вказівником-власником.

1
Задача
C++ SELF, 42 рівень, 3 лекція
Недоступна
Число в коробці
Число в коробці
1
Задача
C++ SELF, 42 рівень, 3 лекція
Недоступна
Нік без зобов’язань
Нік без зобов’язань
1
Задача
C++ SELF, 42 рівень, 3 лекція
Недоступна
Нотатка з деталями
Нотатка з деталями
1
Задача
C++ SELF, 42 рівень, 3 лекція
Недоступна
Точки виходу
Точки виходу
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ