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)); выглядит так:

ШагЧто происходит
1std::move(a) просто приводит тип a от lvalue к rvalue-ссылке, сам объект a не трогает
2Компилятор видит вызов конструктора с аргументом типа rvalue-ссылка и выбирает move-конструктор, а не copy-конструктор
3Move-конструктор копирует указатель 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-конструктора.
Проверьте себя
1. Что на самом деле делает std::move(x)?
AКопирует x в новую временную переменную
BПриводит тип x к rvalue-ссылке, не изменяя сам объект x
CНемедленно освобождает память, занятую x
DВызывает move-конструктор класса x
2. Почему move-конструктор обязательно обнуляет указатель у объекта-источника?
AЧтобы уменьшить использование памяти во время работы программы
BЧтобы деструктор источника не попытался освободить ту же память повторно (double free)
CТак требует синтаксис C++, иначе код не скомпилируется
DЧтобы ускорить сборку мусора