1. deep copy иногда слишком дорогая роскошь
В прошлой теме мы уже разобрали ситуацию, когда объект вручную владеет ресурсом (например, массивом из new[]): деструктор обязан освобождать этот ресурс, а копирование «по умолчанию» для указателя почти гарантированно приводит к double‑free или use‑after‑free. Поэтому мы делали deep copy: копирующий конструктор и копирующее присваивание, чтобы каждый объект владел своим массивом.
Если вы только что написали глубокую копию и почувствовали гордость — это нормально, так и должно быть. Но дальше наступает реальная жизнь: вы начинаете писать код, где объект «путешествует» между функциями, временными переменными, возвращается из функций, кладётся в контейнеры. И внезапно выясняется, что вы тратите время на копирование там, где копировать вообще не нужно — ведь часто исходный объект всё равно больше не понадобится.
Представьте, что у вас есть единственный ключ от квартиры (это наш указатель data). Копирование — это как «пойти сделать дубликат ключа» (дорого и долго). А перемещение — это как «передать ключ из рук в руки» (быстро), при этом у того, кто отдал, ключа больше нет (и это важно!). В современном C++ перемещение — фундаментальная идея, поэтому термины вроде move assignment прямо фигурируют в материалах по стандарту и его терминологии.
2. Что значит «переместить объект»
Когда новички слышат «move», они часто думают: «ага, это просто копирование, но быстрее». Это почти правда… и именно поэтому опасно. Правильная мысль такая: мы переносим владение ресурсом, а не «делаем копию». После перемещения у нас остаётся один владелец ресурса, а у источника ресурс становится пустым (например, nullptr).
С точки зрения модели памяти это выглядит так:
flowchart LR
A[Buffer a] -->|data = 0xAAA| M[(heap)]
B[Buffer b] -->|data = nullptr| N[(нет ресурса)]
A2[после move] -->|data = nullptr| X[(нет ресурса)]
B2[получатель] -->|data = 0xAAA| M
Здесь ключевая идея: адрес 0xAAA никуда не «переезжает» физически — он остаётся там же, в heap. «Переезжает» право считать этот адрес своим и освобождать его в деструкторе.
3. Rvalue‑ссылка T&&: «можно разбирать на запчасти»
Сейчас будет момент, когда C++ слегка приподнимет бровь и скажет: «а теперь давайте аккуратно». Мы уже знаем T& — обычную ссылку, которая привязывается к существующему объекту и означает «это тот же самый объект, просто под другим именем». А вот T&& (rvalue‑ссылка) в контексте move‑операций обычно читается так: «передо мной объект‑источник, из которого разрешено забрать ресурс».
Важно держать в голове: T&& — это отдельный вид ссылки. У него есть тонкости в шаблонном коде (там оно иногда превращается в так называемую forwarding reference, и это отдельная вселенная). Мы это сегодня сознательно не трогаем, но сам факт «у T&& бывают нюансы» официально всплывает даже в обсуждениях про deduction guides.
Для нас сегодня достаточно простой практической модели:
- const Buffer& — «дай посмотреть, но не трогать»
- Buffer& — «дай поработать, возможно изменить»
- Buffer&& — «дай забрать ресурс, ты всё равно уже уходишь»
4. Заготовка: Buffer с deep copy
Чтобы не писать «с нуля», будем считать, что у нас есть тип Buffer, который вручную владеет динамическим массивом int[], корректно копируется (deep copy) и корректно освобождается (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. Move‑конструктор: «забрать указатель и обнулить источник»
Move‑конструктор нужен в тот момент, когда вы создаёте новый объект из временного или из объекта, который вы явно разрешили «разобрать». Его задача концептуально простая: взять у other его ресурсы, а other привести в такое состояние, чтобы его деструктор был безопасен. Это обычно называют «пустое состояние»: data == nullptr, size == 0.
Алгоритм move‑конструктора для нашего 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). Реальное перемещение происходит, когда вызывается move‑конструктор или move‑оператор присваивания.
Небольшой пример «как это выглядит в коде»:
#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. Move‑присваивание: «сначала освободи своё, потом забери чужое»
Move‑оператор присваивания (operator=(Buffer&&)) нужен, когда объект уже существует, и вы хотите заменить его ресурс на ресурс другого объекта‑источника. И тут появляется дополнительная обязанность: у левого объекта уже может быть свой массив, и его нужно корректно освободить, иначе утечка.
Важно также соблюдать порядок действий: сначала мы освобождаем старый ресурс слева (потому что он нам больше не нужен), потом забираем новый ресурс справа, а источник справа делаем пустым. При этом стоит помнить про ситуацию «самоприсваивания» (да, даже для move — теоретически возможно написать a = std::move(a);, и лучше не устраивать себе квест).
Схема move‑присваивания:
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: где реально «видно» перемещение
Когда изучаешь move‑семантику, мозг просит «пощупать руками». Самый простой способ — добавить маленькую диагностическую печать, чтобы видеть, какой конструктор/оператор вызвался. Да, в реальном проекте вы так делать не будете (или будете, но потом стыдливо удалите), зато для обучения это отлично работает.
Вот версия, где мы печатаем события:
#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 и отсутствие «дорогой» deep copy вы увидите сразу.
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: в move‑присваивании забыли освободить старый ресурс слева.
Выглядит безобидно: «ну я же всё равно сейчас перепишу data». Но старый адрес при этом теряется, и массив превращается в утечку памяти. В move‑присваивании почти всегда есть строка вида delete[] data; перед тем, как вы забрали ресурс у other.
Ошибка №3: использовать объект после std::move(x), как будто он всё ещё «с данными».
Это логическая ошибка, а не обязательно аварийная: код может «как-то работать», но смысл уже потерян. После std::move(x) следует мысленно зачеркнуть x как «источник данных». Да, его можно присваивать заново и разрушать, но читать из него «как раньше» — плохая привычка, которая ломает программу в неожиданных местах.
Ошибка №4: писать перемещение, но делать внутри глубокую копию.
Иногда из лучших побуждений в move‑конструктор добавляют new[] и копирование элементов, потому что «ну вдруг так безопаснее». Получается странный гибрид: вы обещаете перемещение, а делаете копирование, только менее очевидное. Move‑операции ценны именно тем, что они не выделяют память и не копируют данные, а лишь передают владение.
Ошибка №5: забыть про this == &other в move‑присваивании.
Ситуация a = std::move(a); редкая, но возможная. Если не проверить самоприсваивание, можно случайно удалить собственный ресурс и потом «украсть» его же у себя — то есть украсть уже удалённое. Проверка if (this == &other) return *this; — простая страховка от очень неприятного класса багов.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ