Move-семантика: rvalue-ссылки, std::move, rule of five

До C++11 копирование объектов было единственным способом их передать. Для больших структур это означало дорогое копирование буферов. Move-семантика позволяет передать владение — почти бесплатно. Здесь разберём, как это работает.

lvalue / rvalue T&& std::move Rule of Five

Проблема: копировать — дорого

Представьте, что у вас есть класс, владеющий большим массивом. Каждое копирование выделяет новую память и переливает туда все данные. Если объектов много — это становится узким местом.

класс с ресурсом
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;            // ← копирование? дорого!
}
Что происходит без move-семантики. Когда makeBuffer() возвращает b, компилятор (без оптимизаций) вызывает конструктор копирования: выделяет новый массив на 4 МБ и переливает туда данные. А исходный b тут же уничтожается. Работа впустую — данные можно было просто передать.
Идея move-семантики. Вместо копирования данных — «украсть» указатель у исходного объекта. Забрать ресурс, а исходный обнулить. Это делается за несколько инструкций, независимо от размера данных.

Прежде всего: 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);

Исходный буфер:—
Новый буфер:—
Выделено памяти:—
Скопировано ячеек:—
$ Нажмите «Показать разницу»
move-конструктор — украсть указатель
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; }
};
Что важно заметить. Move-конструктор не «перемещает» байты. Он копирует указатель (8 байт) и обнуляет указатель исходного объекта. Дальше данные по прежнему адресу принадлежат новому объекту. Когда деструктор старого объекта вызовется, он увидит data == nullptr и ничего не удалит — delete[] nullptr безопасен.

Что на самом деле делает std::move

Самое большое разочарование новичка: std::move ничего не двигает. Это просто приведение типа. Он сообщает компилятору: «обращайся с этим lvalue как с rvalue» — то есть разреши вызвать move-конструктор вместо копирующего.

x lvalue · int std::move std::move(x) xvalue · int&& То же самое значение, но другой тип. Никакого перемещения не произошло. Реальное перемещение произойдёт, когда этот xvalue попадёт в move-конструктор.
упрощённая реализация std::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-семантикой

Коварный эффект. Если вы определили деструктор, но забыли определить move-конструктор и 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;
    }
};
Правило нуля (лучший вариант). Не владейте ресурсом напрямую — используйте готовые RAII-обёртки (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Попросить компилятор сгенерировать стандартную реализацию.
Главное правило. Не думайте о move-семантике в каждой строке кода. Пишите классы по правилу нуля — не владейте ресурсом напрямую, используйте vector, string, unique_ptr. Тогда компилятор сгенерирует move-операции корректно, и вы получите всю выгоду автоматически.

Задание

  1. Определите класс DynamicArray, владеющий динамическим массивом через new[]. Напишите для него правило пяти.
  2. Проверьте: если убрать move-конструктор, сколько раз вызовется конструктор копирования при передаче объекта в функцию по значению?
  3. Объясните, почему std::move на const-объекте не приводит к перемещению. Что произойдёт вместо этого?
  4. Почему std::move в return мешает оптимизации NRVO? В каких случаях std::move в return всё-таки нужен?
  5. Перепишите класс DynamicArray по правилу нуля, используя std::vector<int> внутри. Сколько специальных функций теперь нужно определить?
  6. Как move-семантика взаимодействует с unique_ptr? Почему unique_ptr нельзя копировать, но можно перемещать?
  7. Определите шаблонный класс Wrapper<T>, который владеет объектом типа T и предоставляет правила пяти. Убедитесь, что он работает и с примитивами, и со строками, и с векторами.