1. Чому std::unique_ptr не копіюється
Коли ви вперше бачите std::unique_ptr, легко подумати, що це звичайний вказівник: «адресу ж можна копіювати, отже й unique_ptr можна». На рівні адреси — так, її можна копіювати хоч сто разів. Але проблема не в адресі, а у відповідальності: хто саме має звільнити памʼять?
Якщо два обʼєкти вважають себе власниками одного й того самого ресурсу, то в якийсь момент один із них звільнить памʼять, а другий пізніше спробує звільнити її ще раз. Це класичний double-free — баг, який може проявитися не відразу, а «в пʼятницю ввечері на проді» (розробники жартують, що це окремий жанр горору). Тому для unique_ptr копіювання не просто «не рекомендоване», а логічно заборонене.
Як C++ забороняє копіювання
std::unique_ptr<T> влаштовано так, що його не можна копіювати: у нього видалено (deleted) копіювальний конструктор і оператор копіювального присвоювання. Тобто ви навіть випадково не зможете створити копію через присвоювання — компілятор зупинить вас ще на етапі компіляції.
І це чудова новина: краще дістати помилку компіляції, ніж «успішно» скомпілювати програму, яка то падає, то псує памʼять, а то вдає, ніби все добре. З unique_ptr компілятор виступає у ролі суворого охоронця: «другий власник не пройде».
#include <memory>
int main() {
auto a = std::make_unique<int>(10);
auto b = a; // помилка компіляції: копіювання заборонено
}
Тут auto b = a виглядає нешкідливо, але насправді означає: «тепер у ресурсу два власники». Тому компілятор скаже щось на кшталт: use of deleted function (формулювання залежить від компілятора).
2. Переміщення володіння і std::move
Якщо копіювати не можна, виникає практичне запитання: «а як тоді передавати ресурс між частинами програми?» Наприклад, ви створили обʼєкт, а потім хочете віддати його іншій змінній, іншій функції або іншому фрагменту логіки.
Відповідь: переміщення (move). Це не «створити ще одну копію власника», а «передати володіння від одного власника до іншого». Метафора проста: є ключі від квартири (ресурс) і є людина, яка ними володіє (unique_ptr). Переміщення — це передати ключі іншій людині. Після цього в першої ключів більше немає, але сама людина нікуди не зникає.
Схема виглядає приблизно так:
flowchart LR
A["a: unique_ptr (володіє ресурсом)"] -->|"std::move(a)"| B["b: unique_ptr (стає власником)"]
A2["a: unique_ptr (порожній, nullptr)"] --- B
І в коді це виглядає так:
#include <iostream>
#include <memory>
#include <utility>
int main() {
auto a = std::make_unique<int>(10);
auto b = std::move(a); // переміщення володіння
std::cout << std::boolalpha;
std::cout << (a == nullptr) << '\n'; // true
std::cout << *b << '\n'; // 10
}
Зверніть увагу: a після переміщення залишається валідним обʼєктом, але зазвичай стає порожнім (nullptr). Це нормально й очікувано.
Що робить std::move насправді
Назва std::move — одне з найбільших джерел плутанини в історії C++. Новачки бачать «move» і думають: «ага, це функція, яка прямо зараз переносить дані». Але ні: std::move(x) за змістом ближче до фрази «я дозволяю ставитися до x як до обʼєкта, який можна перемістити».
Технічно std::move — це перетворення, яке робить вираз rvalue (грубо кажучи, «тимчасовим» або таким, який можна віддати). Саме переміщення виконує не std::move, а той код, який отримує результат std::move(x) і вміє переміщувати.
Поганий, але дуже повчальний приклад:
#include <memory>
#include <utility>
int main() {
auto p = std::make_unique<int>(5);
std::move(p); // саме по собі нічого не змінює: результат ніхто не забрав
// p усе ще володіє ресурсом
}
Тут std::move(p); — майже беззмістовний рядок. Він лише «попросив» компілятор розглядати p як такий, що віддається, але ніхто цього значення не прийняв.
А от справжній зміст std::move видно тут:
#include <memory>
#include <utility>
int main() {
auto p = std::make_unique<int>(5);
auto q = std::move(p); // q прийняв володіння
}
Невеликий занудний факт
Стандартна бібліотека дуже прискіпливо описує поведінку переміщення. Настільки прискіпливо, що в редакційних звітах і списках виправлень стандарту трапляються окремі пункти про коректність постумов після переміщувального присвоювання unique_ptr, а також про гарантії noexcept для переміщувальних операцій.
Це добрий сигнал: тема не «магічна», а чітко визначена.
Переміщення під час присвоювання
Переміщення буває не лише «під час створення нової змінної» (auto b = std::move(a);), а й під час присвоювання вже наявній змінній. І тут зʼявляється важлива деталь: що робити з ресурсом, яким unique_ptr ліворуч володів раніше?
Відповідь: unique_ptr ліворуч спочатку звільняє свій старий ресурс, а потім забирає ресурс у правого. Тобто операція безпечна й самодостатня: памʼять не «губиться» і не починає належати двом власникам.
#include <iostream>
#include <memory>
#include <utility>
int main() {
auto a = std::make_unique<int>(1);
auto b = std::make_unique<int>(2);
b = std::move(a); // b тепер володіє тим, чим володів a
std::cout << std::boolalpha;
std::cout << (a == nullptr) << '\n'; // true
std::cout << *b << '\n'; // 1
}
У цій ситуації «старий» ресурс b (де було 2) автоматично звільняється всередині переміщувального присвоювання. Ви не пишете delete, не викликаєте «очищення» вручну — RAII і далі працює.
Стан після переміщення
Після std::move люди часто нервують: «а змінна тепер зламана?» Ні. Вона валідна: її можна знищити, можна присвоїти їй нове значення, можна порівняти з nullptr. Але одного робити не можна: не можна розіменовувати її, доки не переконаєтеся, що вона не порожня.
Для unique_ptr нормальний і очікуваний стан після переміщення — це nullptr. Це не «помилка», а чесний сигнал: володіння передано.
Ось коротка памʼятка «можна / не можна» для unique_ptr після переміщення:
| Дія | Можна? | Чому |
|---|---|---|
|
так | перевірка стану безпечна |
| присвоїти новий ресурс | так | unique_ptr знову стане власником |
| знищити (p виходить за межі області видимості) | так | знищення порожнього власника безпечне |
або |
ні (без перевірки) | p може бути nullptr |
Приклад «правильної дисципліни»:
#include <iostream>
#include <memory>
#include <utility>
int main() {
auto p = std::make_unique<int>(7);
auto q = std::move(p);
if (p) {
std::cout << *p << '\n';
} else {
std::cout << "p порожній\n"; // p порожній
}
std::cout << *q << '\n'; // 7
}
3. Практика: чернетка завдання
Щоб переміщення володіння не залишилося абстрактною магією, вбудуймо його в невеликий сценарій. Уявімо, що в нас є консольний застосунок-органайзер: ми зберігаємо завдання у std::vector<Task>. Іноді користувач набирає чернетку завдання, але ще не додає її до списку. Чернетку або приймуть, або відкинуть. Це добре лягає на модель одноосібного володіння.
Зробімо Task і змінну draft, яка або порожня, або володіє чернеткою:
#include <memory>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
int main() {
std::vector<Task> tasks;
std::unique_ptr<Task> draft; // чернетка: або є, або немає
}
Тепер додамо створення чернетки. Ми ще не обговорювали «ідеальні» сигнатури функцій для unique_ptr (це буде в наступній лекції), тож поки зробімо максимально прямолінійно: створімо обʼєкт і перемістімо його в draft.
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Task {
int id{};
std::string title;
};
int main() {
auto tmp = std::make_unique<Task>(Task{1, "Прочитати про std::move"});
std::unique_ptr<Task> draft = std::move(tmp);
std::cout << (tmp == nullptr) << '\n'; // 1 (true)
std::cout << draft->title << '\n'; // Прочитати про std::move
}
Тобто tmp був тимчасовим власником, а потім передав володіння чернетці. У реальному коді такий тимчасовий власник може зʼявитися не тому, що вам так захотілося, а тому, що ви отримали unique_ptr з іншого місця і хочете передати його далі.
Тепер уявімо, що ми хочемо «прийняти чернетку»: додати завдання в tasks. Оскільки tasks зберігає значення Task, а не володіння через вказівники, ми просто копіюємо або переміщуємо сам Task (обʼєкт) у вектор. Новачкові тут корисно побачити різницю: unique_ptr керує часом життя, а вектор зберігає самі дані.
#include <memory>
#include <string>
#include <vector>
struct Task {
int id{};
std::string title;
};
int main() {
std::vector<Task> tasks;
auto draft = std::make_unique<Task>(Task{1, "Завершити лекцію 212"});
tasks.push_back(*draft); // додаємо сам Task за значенням
draft = nullptr; // чернетка порожня (обʼєкт із draft буде видалено)
}
Рядок draft = nullptr; звільняє ресурс, яким володів власник. Ми навмисно використовуємо присвоювання nullptr, тому що складніші операції керування (reset, release, get) — тема наступних лекцій.
4. Типові помилки зі std::move і unique_ptr
Коли ідея переміщення стає зрозумілою, виникає нова спокуса: почати ставити std::move всюди, ніби це універсальна приправа. Але std::move — не приправа, а попереджувальний знак: «я зараз віддам це значення». Тому типові помилки тут такі: або ви забули std::move там, де він потрібен, або поставили його там, де він ламає логіку.
Помилка № 1: спроба копіювати unique_ptr «за звичкою».
Зазвичай це виглядає як auto b = a; або b = a;. Компілятор насвариться — і це добре. Рішення просте: якщо ви справді хочете передати володіння, використайте std::move(a). Якщо передавати володіння не потрібно, отже, тут вам узагалі не потрібен unique_ptr — потрібен доступ без володіння (про це поговоримо далі).
Помилка № 2: використання змінної після std::move, ніби нічого не сталося.
Після auto b = std::move(a); змінна a дуже часто стає nullptr. Розіменувати *a або a->field після цього — майже гарантований шлях до падіння. Корисна звичка має бути така: побачили std::move(a) — подумки викреслили a як власника й використовуйте її далі лише після if (a) або після присвоювання нового ресурсу.
Помилка № 3: віра в те, що std::move сам звільняє памʼять.
std::move нічого не видаляє і не викликає delete. Він лише «позначає» вираз як такий, що віддається. Звільнення памʼяті відбудеться або всередині операції переміщення unique_ptr (коли один власник замінює ресурс), або коли власника буде знищено (він вийде за межі області видимості), або коли ви явно зробите його порожнім (наприклад, p = nullptr).
Помилка № 4: забули підключити <utility> і здивувалися, що std::move «не існує».
Це дрібниця, але таке трапляється часто: ви підключили <memory>, бо потрібен unique_ptr, і логічно вирішили, що там само лежить std::move. Але std::move живе в <utility>. Якщо компілятор свариться на невідомий std::move, не варто сперечатися з ним — просто підключіть потрібний заголовок.
Помилка № 5: спроба «перемістити з const».
Якщо у вас const std::unique_ptr<T> p, то це обʼєкт, який за контрактом не можна змінювати. А переміщення unique_ptr змінює джерело (зазвичай робить його порожнім). Тому «перемістити з const-власника» не можна. Зазвичай це проявляється як неочікувана помилка компіляції в місці, де ви написали std::move(p), а воно не компілюється. Сенс тут простий і правильний: не можна одночасно обіцяти «не зміню» і «віддам володіння».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ