JavaRush /Курси /C++ SELF /Конструктор переміщення й оператор присвоювання переміщен...

Конструктор переміщення й оператор присвоювання переміщенням

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

1. Глибоке копіювання інколи буває надто дорогою розкішшю

У попередній темі ми вже розібрали ситуацію, коли обʼєкт вручну володіє ресурсом (наприклад, масивом із new[]): деструктор зобовʼязаний звільняти цей ресурс, а копіювання «за замовчуванням» для вказівника майже гарантовано призводить до double‑free або use‑after‑free. Тому ми реалізовували глибоке копіювання: копіювальний конструктор і копіювальне присвоювання, щоб кожен обʼєкт володів власним масивом.

Якщо ви щойно написали глибоке копіювання і відчули гордість, це нормально — так і має бути. Але далі починається реальне життя: ви пишете код, у якому обʼєкт передається між функціями, потрапляє в тимчасові змінні, повертається з функцій, опиняється в контейнерах. І раптом зʼясовується, що ви витрачаєте час на копіювання там, де воно взагалі не потрібне, адже часто вихідний обʼєкт вам уже не знадобиться.

Уявіть, що у вас є єдиний ключ від квартири — це наш вказівник data. Копіювання — це «піти зробити дублікат ключа»: довго й дорого. А переміщення — це «передати ключ із рук у руки»: швидко. Водночас той, хто його віддав, ключа більше не має — і це важливо. У сучасному C++ переміщення — фундаментальна ідея, тому терміни на кшталт move assignment регулярно трапляються в матеріалах стандарту й повʼязаній із ним термінології.

2. Що означає «перемістити обʼєкт»

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

З погляду моделі памʼяті це виглядає так:

flowchart LR
    A[Buffer a] -->|data = 0xAAA| M[(купа)]
    B[Buffer b] -->|data = nullptr| N[(ресурсу немає)]

    A2[після переміщення] -->|data = nullptr| X[(ресурсу немає)]
    B2[отримувач] -->|data = 0xAAA| M

Тут ключова ідея така: адреса 0xAAA фізично нікуди не «переїжджає» — вона залишається там само, у купі. «Переїжджає» лише право вважати цю адресу своєю та звільнити її в деструкторі.

3. Rvalue‑посилання T&&: «можна розбирати на запчастини»

Зараз буде момент, коли C++ трохи підніме брову й скаже: «а тепер давайте обережно». Ми вже знаємо T& — звичайне посилання, яке привʼязується до наявного обʼєкта й означає: «це той самий обʼєкт, просто під іншим імʼям». А от T&& (rvalue‑посилання) у контексті операцій переміщення зазвичай читається так: «перед нами обʼєкт‑джерело, з якого дозволено забрати ресурс».

Важливо тримати в голові: T&& — це окремий вид посилання. У нього є тонкощі в шаблонному коді: там воно іноді перетворюється на так зване forwarding reference, і це вже окремий всесвіт. Сьогодні ми свідомо цього не чіпаємо, але сам факт, що в T&& бувають нюанси, офіційно спливає навіть в обговореннях про deduction guides.

Для нас сьогодні достатньо простої практичної моделі:

  • const Buffer& — «дай подивитися, але не чіпати»
  • Buffer& — «дай попрацювати, можливо, щось змінити»
  • Buffer&& — «дай забрати ресурс, ти все одно вже йдеш»

4. Заготовка: Buffer із глибоким копіюванням

Щоб не писати все з нуля, вважатимемо, що в нас є тип Buffer, який вручну володіє динамічним масивом int[], коректно копіюється через глибоке копіювання й коректно звільняється за принципом RAII.

Нижче — компактна версія без зайвих деталей, від якої ми відштовхуватимемося:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}

    ~Buffer() {
        delete[] data;
    }

    Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
        for (std::size_t i = 0; i < size; ++i) data[i] = other.data[i];
    }

    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;

        int* new_data = new int[other.size];
        for (std::size_t i = 0; i < other.size; ++i) new_data[i] = other.data[i];

        delete[] data;
        data = new_data;
        size = other.size;
        return *this;
    }
};

Ця версія вже коректна, але якщо ви почнете активно передавати Buffer туди-сюди, то майже всюди платитимете за копіювання: виділення памʼяті плюс цикл.

5. Конструктор переміщення: «забрати вказівник і занулити джерело»

Конструктор переміщення потрібен тоді, коли ви створюєте новий обʼєкт із тимчасового або з обʼєкта, якому ви явно дозволили «розібратися». Його завдання концептуально просте: забрати в other його ресурси, а other привести до такого стану, щоб його деструктор залишався безпечним.

Зазвичай це називають «порожнім станом»: data == nullptr, size == 0.

Алгоритм конструктора переміщення для нашого Buffer можна описати так:

flowchart TD
    A["Початок: Buffer(Buffer&& other)"] --> B[Скопіювати size і data з other]
    B --> C[Обнулити other.size]
    C --> D["Обнулити other.data (nullptr)"]
    D --> E[Готово: тепер ресурс у *this*]

Реалізація:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }
};

Зверніть увагу на характерну «нахабність»: ми не виділяємо памʼять і не копіюємо елементи. Ми просто переписуємо два поля й робимо джерело порожнім. Саме в цьому й полягає перенесення володіння.

6. std::move: це не «перемістити», а «дозволити перемістити»

Тут дуже легко спіткнутися об саму назву. std::move(x) сам собою нічого не переміщує. Він лише дає змогу компілятору сприймати x як обʼєкт, який можна переміщати, тобто як rvalue. Реальне переміщення відбувається, коли викликається конструктор переміщення або оператор присвоювання переміщенням.

Невеликий приклад того, як це виглядає в коді:

#include <utility>

int main() {
    Buffer a{5};
    Buffer b = std::move(a); // викликає Buffer(Buffer&&)

    // після цього a.data == nullptr, a.size == 0 (за нашим задумом)
}

Якщо ви забудете std::move, компілятор вважатиме, що ви хочете копіювати, бо a — іменована змінна, а отже lvalue, і спробує викликати копіювальний конструктор.

7. Присвоювання переміщенням: «спочатку звільни своє, потім забери чуже»

Оператор присвоювання переміщенням (operator=(Buffer&&)) потрібен, коли обʼєкт уже існує, а ви хочете замінити його ресурс ресурсом іншого обʼєкта‑джерела. І тут зʼявляється додатковий обовʼязок: у лівого обʼєкта вже може бути власний масив, і його потрібно коректно звільнити, інакше виникне витік.

Важливо також дотримуватися правильного порядку дій: спочатку ми звільняємо старий ресурс ліворуч, бо він нам більше не потрібен. Потім забираємо новий ресурс праворуч, а джерело праворуч робимо порожнім. Водночас варто памʼятати і про самоприсвоювання: так, навіть для move теоретично можливо написати a = std::move(a);, і краще не влаштовувати собі такий квест.

Схема присвоювання переміщенням:

flowchart TD
    A["Початок: operator=(Buffer&& other)"] --> B{this == &other?}
    B -->|так| C[Повернути *this]
    B -->|ні| D[Звільнити поточний data]
    D --> E[Забрати other.size і other.data]
    E --> F[Зробити other порожнім]
    F --> G[Повернути *this]

Реалізація — коротко й по суті:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        if (this == &other) return *this;

        delete[] data;          // звільняємо старий ресурс ліворуч
        size = other.size;      // забираємо новий
        data = other.data;

        other.size = 0;         // обнуляємо джерело
        other.data = nullptr;
        return *this;
    }
};

Ця версія вже робить головне: запобігає витокам ліворуч і double‑free праворуч.

8. Мінідемонстрація в main: де переміщення справді «видно»

Коли вивчаєш семантику переміщення, мозок просить «помацати руками». Найпростіший спосіб — додати невеликий діагностичний вивід, щоб бачити, який конструктор або оператор викликався. Так, у реальному проєкті ви так не робитимете. Або робитимете, але потім соромʼязливо видалите. Зате для навчання це чудово працює.

Ось версія, у якій ми друкуємо події:

#include <cstddef>
#include <iostream>
#include <utility>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {
        std::cout << "ctor(" << size << ")\n"; // ctor(3)
    }

    ~Buffer() {
        std::cout << "dtor(" << size << ")\n"; // dtor(...)
        delete[] data;
    }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        std::cout << "move-ctor\n";            // move-ctor
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        std::cout << "move-assign\n";          // move-assign
        if (this == &other) return *this;

        delete[] data;
        size = other.size;
        data = other.data;

        other.size = 0;
        other.data = nullptr;
        return *this;
    }
};

int main() {
    Buffer a{3};
    Buffer b{1};

    b = std::move(a);
}

Вивід залежатиме від порядку руйнування обʼєктів наприкінці main, але ключовий рядок move-assign і відсутність «дорогого» глибокого копіювання ви побачите одразу.

9. Контракт moved‑from і порівняння copy vs move

Обʼєкт‑джерело після move залишається «живим»

Тут хочеться сказати: «після std::move обʼєкт зламаний». І це поширений міф. Він не «зламаний», він просто більше не зобовʼязаний зберігати старі дані. Його стан зазвичай описують як «коректний, але не визначений за вмістом»; у побутовому сенсі це означає: «там може бути порожньо, і це нормально». У наступних лекціях ми сформулюємо це акуратніше й суворіше, але вже зараз корисно звикнути до думки: moved‑from обʼєкт має вміти безпечно руйнуватися і приймати нове значення.

Практично для нашого Buffer ми самі вибрали дизайн: moved‑from стан — це (size = 0, data = nullptr). Це зручно, тому що:

  • delete[] nullptr; безпечний;
  • можна написати if (data == nullptr) як індикатор порожнечі;
  • можна знову присвоїти новий буфер, і все буде нормально.

І так, тема «move‑операції часто позначають noexcept» справді настільки важлива, що навколо неї є окремі обговорення й рішення навіть для стандартних типів, наприклад для std::function та інших. Але деталі noexcept ми розберемо в наступній лекції, щоб не змішувати дві великі ідеї в одну кашу.

Copy vs move: таблиця

Щоб закріпити відмінність, зручно тримати перед очима ось таку «людську» таблицю:

Операція Що робить Вартість (зазвичай) Що з джерелом
Copy (deep copy) виділяє новий ресурс і копіює дані дорога: new[] + цикл джерело не змінюється
Move передає володіння ресурсом (вказівник/розмір) дешева: пара присвоювань джерело стає порожнім / «неважливим»

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

10. Типові помилки

Помилка № 1: забрали other.data, але не занулили other.data.
Це класичний шлях до double‑free: в отримувача тепер правильний вказівник, але в джерела залишилася та сама адреса, і при виході з області видимості обидва деструктори спробують звільнити один і той самий масив. Розвʼязується це простою дисципліною: після «крадіжки ресурсу» джерело відразу, у цьому ж методі, приводиться до порожнього стану.

Помилка № 2: у присвоюванні переміщенням забули звільнити старий ресурс ліворуч.
На вигляд усе невинно: «ну я ж однаково зараз перепишу data». Але стару адресу при цьому втрачено, і масив перетворюється на витік памʼяті. У присвоюванні переміщенням майже завжди є рядок на кшталт delete[] data; перед тим, як ви заберете ресурс у other.

Помилка № 3: використовувати обʼєкт після std::move(x) так, ніби він усе ще «з даними».
Це логічна помилка, а не обовʼязково аварійна: код може «якось працювати», але сенс уже втрачено. Після std::move(x) слід подумки закреслити x як «джерело даних». Так, його можна знову присвоювати і руйнувати, але читати з нього «як раніше» — погана звичка, яка ламає програму в найнесподіваніших місцях.

Помилка № 4: писати переміщення, але всередині робити глибоке копіювання.
Іноді з найкращих намірів у конструктор переміщення додають new[] і копіювання елементів, бо «раптом так безпечніше». У результаті виходить дивний гібрид: ви обіцяєте переміщення, а насправді робите копіювання, просто менш очевидне. Операції переміщення цінні саме тим, що вони не виділяють памʼять і не копіюють дані, а лише передають володіння.

Помилка № 5: забути про this == &other у присвоюванні переміщенням.
Ситуація a = std::move(a); рідкісна, але можлива. Якщо не перевірити самоприсвоювання, можна випадково видалити власний ресурс, а потім «вкрасти» його ж у себе — тобто вкрасти вже видалене. Перевірка if (this == &other) return *this; — проста страховка від дуже неприємного класу багів.

1
Опитування
=default/=delete, рівень 45, лекція 4
Недоступний
=default/=delete
=default/=delete
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ