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; — проста страховка від дуже неприємного класу багів.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ