Move-конструктор и std::move
Классический вопрос: «Что делает std::move?» Половина кандидатов отвечает «перемещает объект». Это неверно — и сейчас разберём почему.
Move-конструктор — специальный конструктор, который забирает ресурсы у временного объекта вместо их копирования. std::move ничего не перемещает — это просто приведение lvalue к типу rvalue-ссылки, сигнал компилятору «можно распоряжаться этим значением как временным».
Вопрос-крючок
Смотри на код и попробуй сказать, что выведется:
#include <iostream>
#include <vector>
#include <utility>
class Buffer {
public:
std::vector<int> data;
Buffer(std::vector<int> d) : data(std::move(d)) {
std::cout << "Создан Buffer, размер: " << data.size() << std::endl;
}
};
int main() {
std::vector<int> source = {1, 2, 3, 4, 5};
std::cout << "До move, size: " << source.size() << std::endl;
Buffer b(std::move(source));
std::cout << "После move, size исходного вектора: " << source.size() << std::endl;
}Вывод:
До move, size: 5
Создан Buffer, размер: 5
После move, size исходного вектора: 0Ключевая строка — последняя. Вектор source после std::move опустел. Многие думают, что std::move сам его опустошил. На самом деле std::move тут вообще ни при чём — опустошением занимался move-конструктор std::vector. Разбираем по шагам.
Что делает std::move на самом деле (это просто приведение типа!)
Вот честная реализация std::move, максимально упрощённая:
template<typename T>
std::remove_reference_t<T>&& move(T&& arg) {
return static_cast<std::remove_reference_t<T>&&>(arg);
}Смотри, что тут происходит: функция принимает значение и возвращает то же самое значение, просто с типом «rvalue-ссылка». Она не копирует данные, не двигает байты в памяти, не вызывает никаких деструкторов. Это ровно static_cast — обычное приведение типа, только под удобным названием.
То есть строчка std::move(source) буквально означает: «Не трогай сам объект source, просто временно скажи компилятору, что с этим значением можно обращаться так, будто оно rvalue — временное и одноразовое». Имя функции вводит в заблуждение: было бы честнее назвать её rvalue_cast, но название move прижилось ещё на стадии обсуждения стандарта.
Реальное «перемещение» — то есть код, который действительно ворует указатель на буфер и обнуляет источник, — происходит только потом, когда это приведённое к rvalue-ссылке значение попадает в move-конструктор или move-оператор присваивания. Если бы такого конструктора не было, значение бы просто скопировалось как обычно — std::move сам по себе гарантий не даёт.
Как переместить ресурс вместо копирования
Теперь посмотрим, как выглядит move-конструктор изнутри — напишем свой класс с ручным управлением памятью, чтобы увидеть механику без магии стандартной библиотеки:
class IntArray {
public:
int* data;
size_t size;
// Обычный конструктор
IntArray(size_t n) : data(new int[n]), size(n) {
std::cout << "Обычное создание, size=" << size << std::endl;
}
// Move-конструктор
IntArray(IntArray&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr; // забрали — обнулили источник
other.size = 0;
std::cout << "Move-конструктор, украли size=" << size << std::endl;
}
~IntArray() {
delete[] data; // безопасно, delete[] nullptr ничего не делает
}
};
int main() {
IntArray a(1000);
IntArray b(std::move(a));
std::cout << "a.data после move: " << a.data << std::endl;
std::cout << "b.size: " << b.size << std::endl;
}Вывод:
Обычное создание, size=1000
Move-конструктор, украли size=1000
a.data после move: 0
b.size: 1000Смотри внимательно на move-конструктор. Он принимает IntArray&& other — rvalue-ссылку. Внутри он не выделяет новую память через new, а просто копирует указатель other.data себе в data. Один указатель — это несколько байт, а не тысяча int'ов. После этого он обязательно обнуляет other.data в nullptr. Это критичный шаг: если этого не сделать, при разрушении обоих объектов деструктор вызовет delete[] дважды на один и тот же адрес — программа упадёт или испортит память.
Именно в этом разница с обычным (copy) конструктором: copy-конструктор выделяет новую память и копирует туда все элементы один за другим — это дорого при больших объёмах данных. Move-конструктор — это просто «переписать три поля и обнулить источник», и стоит константное время независимо от размера данных внутри.
Как это работает под капотом
Полная цепочка событий в строке IntArray b(std::move(a)); выглядит так:
| Шаг | Что происходит |
| 1 | std::move(a) просто приводит тип a от lvalue к rvalue-ссылке, сам объект a не трогает |
| 2 | Компилятор видит вызов конструктора с аргументом типа rvalue-ссылка и выбирает move-конструктор, а не copy-конструктор |
| 3 | Move-конструктор копирует указатель data (не содержимое!) и обнуляет источник |
| 4 | Когда a выходит из области видимости, его деструктор вызывает delete[] nullptr — безопасный no-op |
Отсюда важное следствие: объект после std::move не уничтожается. Он остаётся живым, у него по-прежнему вызовется деструктор в конце области видимости — просто он теперь «пустой». Стандарт формулирует это как «валидное, но неопределённое состояние» (valid but unspecified state): с объектом можно безопасно работать (например, присвоить ему новое значение), но нельзя полагаться на то, что в нём осталось что-то конкретное.
Частые ошибки на собеседовании
- «std::move физически перемещает данные». Нет — это просто приведение типа. Реальное перемещение (кража ресурсов) делает move-конструктор или move-оператор присваивания того класса, к которому применили std::move.
- Использование объекта после std::move как обычного. Частая забагованная строчка на собеседовании: после
func(std::move(obj))кандидат в следующей строке читает поляobj, как будто ничего не случилось. Это неопределённое поведение с точки зрения содержимого — обращаться можно, но нельзя рассчитывать на конкретное значение. - Забывают noexcept у move-конструктора. Если move-конструктор не помечен
noexcept, некоторые контейнеры вродеstd::vectorпри реаллокации предпочтут откатиться на copy-конструктор — просто чтобы гарантировать безопасность отката при исключении. Это тихо убивает всю выгоду от оптимизации. - Путают std::move с гарантией перемещения. Если у класса нет move-конструктора,
std::moveвсё равно скомпилируется, но вызовется обычный copy-конструктор — просто потому что это единственный подходящий кандидат. std::move только открывает возможность, а не гарантирует её.
Итоги
std::move— не функция перемещения, а приведение типа: превращает lvalue в rvalue-ссылку, не трогая сам объект.- Реальную работу — кражу ресурсов и обнуление источника — делает move-конструктор (или move-оператор присваивания) класса.
- Move-конструктор копирует лёгкие поля (указатели, размеры), а не тяжёлое содержимое, поэтому работает за константное время.
- После
std::moveисходный объект остаётся живым, но в «валидном, но неопределённом» состоянии — использовать можно, полагаться на содержимое нельзя. - Move-конструктор стоит помечать
noexcept, иначе контейнеры вродеvectorмогут проигнорировать его в пользу copy-конструктора.