Добавить в избранное

Move-семантика в C++: rvalue, std::move и почему копирование не нужно

Чтение ≈ 4 мин

Разбираем семантику перемещения: lvalue и rvalue, конструктор перемещения и move-присваивание, правило пяти, std::move без магии и типичные ошибки при работе с перемещаемыми объектами.

Проблема, которую решало перемещение

До C++11 любое «отдать объект» означало «скопировать». Вернёте из функции вектор на миллион элементов — получите глубокую копию. Поместите тяжёлый объект в контейнер — ещё одну. Компиляторы умели RVO, но оно срабатывало не всегда.

Move-семантика добавила в язык идею: если источник больше не нужен, вместо копирования украдём у него ресурсы. Скопировать указатель на кучу, обнулить источник — и вместо копирования миллиона элементов у нас три машинных инструкции.

std::vector<int> a = fillVector();      // C++11+: ресурсы переедут, а не скопируются
std::vector<int> b = std::move(a);      // b забрал буфер, a стал пустым

lvalue и rvalue

Чтобы перемещение работало, компилятору нужно знать: этот объект ещё пригодится или его можно «разобрать на запчасти».

  • lvalue — выражение с именем, на него можно взять адрес: переменные, *ptr, v[i], a.b.
  • rvalue — временное: результат функции по значению, литерал, x + y, std::move(x).

Ссылка T&& (rvalue-ссылка) может привязываться только к rvalue — и это механизм выбора перегрузок:

void f(std::string& s);       // вызывается для lvalue — копировать нельзя
void f(std::string&& s);      // вызывается для rvalue — можно воровать

std::string name = "x";
f(name);                      // lvalue → первая версия
f(std::string("tmp"));        // временный → вторая
f(std::move(name));           // принудительно превратили в rvalue

std::move — это просто приведение типа

std::move ничего не перемещает. Это static_cast<T&&> — «пометь объект как расходник». Реальное перемещение делает move-конструктор или move-оператор присваивания вызываемой стороны.

std::string src = "большие данные";
std::string dst = std::move(src);
// перемещение сделал конструктор string(string&&): он забрал буфер
// src валиден, но в "перемещённом" состоянии — обычно пуст

После std::move на вас лежит обязанность: не использовать перемещённый объект, кроме операций, безопасных для любого состояния — присвоить новое значение, вызвать clear(), деструировать. Читать его содержимое нельзя — там «неуточнённое, но валидное» состояние.

Пишем класс с move-семантикой: правило пяти

Если класс владеет ресурсом, ему нужны пять функций — деструктор, копирующая пара и перемещающая пара. Это «правило пяти» (бывшее «правило трёх» до C++11):

class Buffer {
    char* data_ = nullptr;
    size_t size_ = 0;
public:
    explicit Buffer(size_t n) : data_(new char[n]), size_(n) {}
    ~Buffer() { delete[] data_; }

    // копирование — глубокое
    Buffer(const Buffer& o) : data_(new char[o.size_]), size_(o.size_) {
        std::copy(o.data_, o.data_ + size_, data_);
    }
    Buffer& operator=(const Buffer& o) {
        if (this != &o) {
            auto* fresh = new char[o.size_];
            std::copy(o.data_, o.data_ + o.size_, fresh);
            delete[] data_;           // сначала новое — и только потом удаляем старое
            data_ = fresh; size_ = o.size_;
        }
        return *this;
    }

    // перемещение — воруем указатель
    Buffer(Buffer&& o) noexcept : data_(o.data_), size_(o.size_) {
        o.data_ = nullptr; o.size_ = 0;
    }
    Buffer& operator=(Buffer&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = o.data_; size_ = o.size_;
            o.data_ = nullptr; o.size_ = 0;
        }
        return *this;
    }
};

Обратите внимание на noexcept у перемещающих операций: контейнеры вроде vector при реаллокации выбирают move только если он noexcept, иначе используют копию ради строгой гарантии исключений. Забыть noexcept — значит молча замедлить свой класс.

Где move-семантика срабатывает сама

  • Возврат по значению из функции — автоматически rvalue, перемещение или RVO без копии.
  • push_back(std::move(obj)) — объект переезжает в контейнер.
  • std::swap — реализован через три перемещения.
  • Параметры-«воронки»: void setName(std::string n) — вызывающий решает, копировать (setName(name)) или перемещать (setName(std::move(name))).

Perfect forwarding и && в шаблонах

В шаблонах T&& — это не «rvalue-ссылка», а forwarding reference: схлопывается по правилам вывода и может стать и lvalue-ссылкой. Чтобы передать аргумент дальше с сохранением категории, используют std::forward:

template<class T>
void wrapper(T&& arg) {
    realWorker(std::forward<T>(arg));  // rvalue останется rvalue, lvalue — lvalue
}

Именно так работают emplace_back, make_unique, bind — принимают аргументы и прокидывают в конструктор без лишних копий.

Типичные ошибки

  • std::move на возврате локальной переменной. return std::move(local); — мешает RVO! Просто return local; — компилятор сам переместит или вообще сконструирует в месте вызова.
  • Использование после move. Классика: цикл по вектору после std::move(v).
  • Move-операции без noexcept — тихая деградация производительности.
  • Константные источники. std::move от const объекта всё равно приводит к копии — move-конструктор хочет неконстантный T&&.
  • Перемещение базовых типов. std::move для int — просто копия, воровать нечего.

Итог

Move-семантика — это про дешёвое «отдать» вместо дорогого «скопировать». Сигнатуры T&&, std::move как «метка расходника», noexcept на перемещающих операциях и правило пяти для классов-владельцев. А главная привычка — не std::move на выходе из функции и не использовать объекты после перемещения.

Проверка: видно ли перемещение

Напишите класс с печатью в конструкторах — и посмотрите, когда что вызывается:

struct Verbose {
    Verbose()                    { puts("ctor"); }
    Verbose(const Verbose&)      { puts("copy"); }
    Verbose(Verbose&&) noexcept  { puts("move"); }
    ~Verbose()                   { puts("dtor"); }
};

Verbose a;              // ctor
Verbose b = a;          // copy
Verbose c = std::move(a); // move

Увидите на практике, где копии, где перемещения — лучший способ почувствовать семантику.

copy-and-swap: элегантное присваивание

Единая идиома для копирующего и перемещающего присваивания через swap:

void swap(Buffer& a, Buffer& b) noexcept {
    std::swap(a.data_, b.data_);
    std::swap(a.size_, b.size_);
}

Buffer& operator=(Buffer o) noexcept {   // по значению — копия или move на входе
    swap(*this, o);
    return *this;
}

Параметр по значению: для lvalue он копируется, для rvalue — перемещается. Одна реализация покрывает оба случая и строгую гарантию исключений заодно.

Практическая метрика

На больших объектах разница измерима: копия строки на 1 МБ — аллокация + memcpy, перемещение — перестановка трёх указателей. В цикле из тысячи операций это разница между миллисекундами и микросекундами. Пишите бенчмарк через std::chrono — убедитесь сами.

Что почитать дальше

  • Rule of Zero / Rule of Five — когда классам вообще не нужны эти функции.
  • std::forward и forwarding references — для шаблонов и perfect forwarding.
  • Move-only типы: unique_ptr, thread, fstream — их нельзя копировать, только перемещать.
Поделиться
Понравился материал?

Комментарии

Добавить комментарий