Проблема: копировать — дорого
Представьте, что у вас есть класс, владеющий большим массивом. Каждое копирование выделяет новую память и переливает туда все данные. Если объектов много — это становится узким местом.
class Buffer {
int* data;
size_t size;
public:
Buffer(size_t n) : data(new int[n]), size(n) {}
~Buffer() { delete[] data; }
// Конструктор копирования — выделяет новую память и копирует всё
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
};
Buffer makeBuffer() {
Buffer b(1000000); // 4 МБ данных
return b; // ← копирование? дорого!
}
makeBuffer()
возвращает b, компилятор (без оптимизаций) вызывает конструктор копирования:
выделяет новый массив на 4 МБ и переливает туда данные. А исходный b
тут же уничтожается. Работа впустую — данные можно было просто передать.
Прежде всего: lvalue и rvalue
Это фундаментальное разделение всех выражений в C++. Пока не разберётесь в нём, move-семантика останется магией.
lvalue — то, у чего есть имя
Стоит слева от знака =.
Имеет адрес в памяти, живёт долго.
- Переменные:
x,buffer,name - Элементы контейнера:
v[0],arr[i] - Разыменование:
*ptr - Доступ к полю:
obj.field
rvalue — временное значение
Стоит справа от =.
Живёт до конца выражения, потом уничтожается.
- Литералы:
42,3.14 - Результаты выражений:
x + y,f() - Временные объекты:
Buffer(1000) - Результат
std::move(x)
Интерактивно: определите категорию
Кликните по элементу в нижнем блоке, чтобы поместить его в нужную категорию. Если ошибётесь — получите подсказку.
lvalue — у чего есть имя
&rvalue — временное значение
Копирование vs Перемещение
Сравнение один в один: что происходит с буфером из 5 ячеек при копировании и при перемещении.
Копирование Buffer copy = original;
Перемещение Buffer moved = std::move(original);
class Buffer {
int* data;
size_t size;
public:
// Копирующий конструктор: выделяет память и переливает все данные
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
// Перемещающий конструктор: ЗАБИРАЕТ указатель
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // ← исходный объект больше не владеет
other.size = 0;
}
~Buffer() { delete[] data; }
};
data == nullptr и ничего не удалит —
delete[] nullptr безопасен.
Что на самом деле делает std::move
Самое большое разочарование новичка: std::move ничего не двигает.
Это просто приведение типа. Он сообщает компилятору: «обращайся с этим lvalue как
с rvalue» — то есть разреши вызвать move-конструктор вместо копирующего.
// Внутри стандартной библиотеки std::move выглядит примерно так:
template<class T>
constexpr remove_reference_t<T>&& move(T&& x) noexcept {
return static_cast<remove_reference_t<T>&&>(x);
}
// То есть:
// std::move(x) — это просто static_cast<T&&>(x)
// Больше ничего.
std::move(x) сам по себе
не меняет объект x. Он просто сообщает компилятору: «этот объект можно
перемещать». Если после std::move не происходит вызова move-конструктора
или move-присваивания, то x остаётся как был.
Buffer a(1000);
Buffer b = std::move(a); // сработает move-конструктор
// Что произойдёт с a?
// a.data == nullptr
// a.size == 0
// Объект a формально «valid, but unspecified state».
// Можно только присвоить ему новое значение или уничтожить.
a = Buffer(500); // OK — присваиваем заново
// cout << a.data[0]; // ← НЕЛЬЗЯ, там nullptr
Правило пяти
Если класс напрямую владеет ресурсом (памятью, файлом, соединением), нужно определить пять специальных функций. Если хотя бы одну пропустить — компилятор сгенерирует её неправильно.
Как это связано с move-семантикой
class Buffer {
int* data;
size_t size;
public:
// 1. Конструктор по умолчанию
Buffer(size_t n = 0) : data(new int[n]), size(n) {}
// 2. Деструктор
~Buffer() { delete[] data; }
// 3. Конструктор копирования
Buffer(const Buffer& other)
: data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
}
// 4. Оператор копирующего присваивания
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data;
size = other.size;
data = new int[size];
std::copy(other.data, other.data + size, data);
}
return *this;
}
// 5. Конструктор перемещения
Buffer(Buffer&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 6. Оператор перемещающего присваивания
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data;
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
unique_ptr, vector,
string). Тогда ни одну из пяти функций писать не нужно: компилятор
сгенерирует все правильно.
Типичные ошибки
1. Использование перемещённого объекта
std::string s = "hello";
std::string t = std::move(s);
// s находится в состоянии «valid but unspecified»
cout << s.size(); // может быть 0, может быть что-то ещё
cout << s[0]; // UB или мусор
После std::move объект можно только уничтожить, присвоить ему новое
значение или передать дальше. Читать данные из него нельзя.
2. std::move на константном объекте
const std::string s = "hello";
std::string t = std::move(s); // НЕ перемещает!
// std::move(const T&) возвращает const T&&
// А move-конструктор принимает T&& (без const)
// Значит, вызывается КОПИРУЮЩИЙ конструктор.
Компилятор не предупредит — просто тихо произойдёт копирование.
Не применяйте std::move к const-объектам.
3. std::move в return
Buffer makeBuffer() {
Buffer b(1000);
return std::move(b); // ЛИШНЕЕ. Вредит оптимизации NRVO.
}
Если просто написать return b;, компилятор применит NRVO
(Named Return Value Optimization) — создаст объект сразу в памяти вызывающего.
Это ещё быстрее, чем move. std::move здесь мешает.
4. Определили деструктор — потеряли move
class Widget {
int* data;
public:
Widget(int n) : data(new int[n]) {}
~Widget() { delete[] data; } // определили деструктор
// move-конструктор НЕ сгенерируется автоматически!
// Вместо него будет использоваться копирование.
};
Правило: если определили деструктор, копирование или присваивание — определите и move-операции. Либо применяйте правило нуля.
5. Move-операции без noexcept
Buffer(Buffer&& other) // без noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
Стандартные контейнеры (например, vector) используют move только
если он помечен noexcept — иначе при перевыделении памяти может
произойти исключение, и контейнер не сможет гарантировать целостность.
Всегда пишите noexcept у move-операций.
Шпаргалка
| Синтаксис | Что означает |
|---|---|
T& | lvalue-ссылка. Может привязаться к именованному объекту. |
T&& | rvalue-ссылка. Может привязаться к временному значению или результату std::move. |
std::move(x) | Приведение x к rvalue-ссылке. Ничего не перемещает. |
Buffer(Buffer&&) | Move-конструктор. Забирает ресурс у аргумента. |
operator=(Buffer&&) | Move-присваивание. Освобождает свой ресурс, забирает ресурс аргумента. |
noexcept | Обещание не выбрасывать исключений. Обязательно для move-операций. |
= delete | Запретить операцию. Например, Buffer(const Buffer&) = delete;. |
= default | Попросить компилятор сгенерировать стандартную реализацию. |
vector, string, unique_ptr. Тогда компилятор
сгенерирует move-операции корректно, и вы получите всю выгоду автоматически.
Задание
- Определите класс
DynamicArray, владеющий динамическим массивом черезnew[]. Напишите для него правило пяти. - Проверьте: если убрать move-конструктор, сколько раз вызовется конструктор копирования при передаче объекта в функцию по значению?
- Объясните, почему
std::moveнаconst-объекте не приводит к перемещению. Что произойдёт вместо этого? - Почему
std::moveвreturnмешает оптимизации NRVO? В каких случаяхstd::moveвreturnвсё-таки нужен? - Перепишите класс
DynamicArrayпо правилу нуля, используяstd::vector<int>внутри. Сколько специальных функций теперь нужно определить? - Как move-семантика взаимодействует с
unique_ptr? Почемуunique_ptrнельзя копировать, но можно перемещать? - Определите шаблонный класс
Wrapper<T>, который владеет объектом типаTи предоставляет правила пяти. Убедитесь, что он работает и с примитивами, и со строками, и с векторами.