JavaRush /Курси /C++ SELF /Переміщення володіння — std::move

Переміщення володіння — std::move

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

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 після переміщення:

Дія Можна? Чому
if (p) / p == nullptr
так перевірка стану безпечна
присвоїти новий ресурс так unique_ptr знову стане власником
знищити (p виходить за межі області видимості) так знищення порожнього власника безпечне
*p
або
p->field
ні (без перевірки) 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), а воно не компілюється. Сенс тут простий і правильний: не можна одночасно обіцяти «не зміню» і «віддам володіння».

1
Задача
C++ SELF, 42 рівень, 4 лекція
Недоступна
Передавання володіння
Передавання володіння
1
Задача
C++ SELF, 42 рівень, 4 лекція
Недоступна
Холостий move
Холостий move
1
Задача
C++ SELF, 42 рівень, 4 лекція
Недоступна
Обмін ключами
Обмін ключами
1
Задача
C++ SELF, 42 рівень, 4 лекція
Недоступна
Шлях чернетки
Шлях чернетки
1
Опитування
RAII і <code><span class="text-user">unique_ptr</span></code>, рівень 42, лекція 4
Недоступний
RAII і unique_ptr
RAII і unique_ptr
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ