STL

Move-семантика и std::move на практике в C++-коде

Введение

До C++11 копирование было единственным способом передать объект «по значению»: если функция возвращала std::vector<int> из миллиона элементов, компилятор (в лучшем случае с помощью оптимизаций) копировал весь буфер. Move-семантика, появившаяся в C++11, ввела понятие «перемещения» — передачи владения ресурсом без его копирования. std::move — это инструмент, который явно говорит компилятору: «мне больше не нужно это значение, можешь забрать его ресурсы».

Концепция

Move-семантика строится на:

  • rvalue-ссылках (T&&) — ссылки, которые могут связываться с временными объектами;
  • move-конструкторе и move-операторе присваивания — специальных перегрузках, которые «опустошают» источник, забирая его внутренние ресурсы (указатели, буферы) вместо их копирования;
  • std::move — это не функция, которая что-то перемещает физически, а просто static_cast<T&&>, то есть приведение lvalue к rvalue-ссылке, разрешающее компилятору выбрать move-перегрузку.

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

Пример кода

#include <iostream>
#include <vector>
#include <utility>
#include <string>

class Buffer {
public:
    explicit Buffer(size_t size) : size_(size), data_(new int[size]) {
        std::cout << "Constructed buffer of size " << size_ << "n";
    }

    // Move-конструктор: забираем указатель, обнуляем источник
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
        std::cout << "Moved buffern";
    }

    // Move-оператор присваивания
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    // Копирующий конструктор: реальное копирование данных (для сравнения)
    Buffer(const Buffer& other) : size_(other.size_), data_(new int[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
        std::cout << "Copied buffer of size " << size_ << "n";
    }

    Buffer& operator=(const Buffer&) = delete; // упростим пример

    ~Buffer() {
        delete[] data_;
    }

    size_t size() const { return size_; }

private:
    size_t size_;
    int* data_;
};

Buffer makeBuffer(size_t size) {
    Buffer b(size);
    return b; // компилятор использует move (или даже RVO/NRVO)
}

int main() {
    std::vector<Buffer> buffers;
    buffers.reserve(3); // избегаем лишних перемещений при росте вектора

    buffers.push_back(makeBuffer(100));      // временный объект -> move
    Buffer named = makeBuffer(200);
    buffers.push_back(std::move(named));     // явный move именованного объекта

    std::cout << "named.size() after move = " << named.size() << "n"; // 0, валидно, но опустошено

    // std::move в реализации swap
    Buffer a(10), b(20);
    Buffer tmp = std::move(a);
    a = std::move(b);
    b = std::move(tmp);
    std::cout << "After swap: a.size()=" << a.size() << ", b.size()=" << b.size() << "n";

    return 0;
}

Пояснения к коду

Buffer владеет сырым массивом int*. Move-конструктор и move-оператор присваивания просто копируют указатель и обнуляют источник — никакого выделения памяти, никакого копирования содержимого, операция выполняется за константное время независимо от размера буфера. Копирующий конструктор оставлен для контраста: он выделяет новую память и копирует все элементы — операция O(n).

В makeBuffer возврат локального объекта b по значению либо подвергается RVO/NRVO (оптимизация исключает копирование/перемещение вовсе), либо, если оптимизация не сработала, компилятор автоматически трактует возврат локальной переменной как rvalue и вызывает move-конструктор — никакого std::move в return писать не нужно (и даже вредно, так как это может помешать NRVO).

Вызов buffers.push_back(std::move(named)) — пример явного перемещения: named — lvalue, и без std::move компилятор выбрал бы копирующий конструктор. После этой строки named гарантированно в опустошённом состоянии (size() == 0), и любое использование как полноценного буфера было бы логической ошибкой (хотя деструктор и size() работают безопасно, потому что класс написан корректно).

Подводные камни и советы

1. Не используйте объект после std::move (кроме как для переинициализации или вызова безопасных методов) — это источник трудноуловимых ошибок. 2. std::move ничего не делает сам по себе — это просто приведение типа. Реальное «перемещение» происходит только если у класса определён move-конструктор/move-оператор; иначе будет вызвано обычное копирование. 3. Move-операции должны быть noexcept, когда это возможно — иначе std::vector при изменении размера будет использовать копирование вместо перемещения (чтобы сохранить строгую гарантию безопасности исключений). 4. Не злоупотребляйте std::move при возврате локальных переменных — это может отключить NRVO. Компилятор сам разберётся. 5. std::move на const объекте бесполезенconst T&& всё равно резолвится в копирующий конструктор, потому что move-конструктор обычно принимает неконстантную rvalue-ссылку.

================================================================================