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 володіння стає частиною типу, тобто частиною контракту, який видно вже з оголошення змінної.
Ось невелика таблиця для порівняння:
| Інструмент | Що зберігає | Хто звільняє ресурс | Типова проблема |
|---|---|---|---|
|
адресу | «хтось десь» | витоки, double-free, use-after-free |
|
адресу + контракт володіння | сам 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 найчастіше просто не потрібен: наявність виражається самим вказівником-власником.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ