Move-семантика в C++: что это и зачем нужно новичку

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что скрывается за умным словом «Move семантика»?

Представьте, что вы переезжаете в новую квартиру. У вас есть огромный, тяжёлый диван. Что быстрее: тащить его на себе через пять лестничных пролётов или просто передать ключи грузчикам и сказать: «Ребята, диван ваш, делайте с ним что хотите, мне он больше не нужен»? Второй вариант — это и есть move-семантика в C++.

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

Move-семантика (семантика перемещения) — это механизм, который позволяет перемещать ресурсы (память, файловые дескрипторы, сокеты) из одного объекта в другой без дорогостоящего копирования. После перемещения исходный объект остаётся в «пустом», но корректном состоянии, и его можно безопасно уничтожить или присвоить ему новое значение.

В этой статье мы разберёмся с rvalue-ссылками, функцией std::move, конструкторами перемещения и операторами присваивания — простыми словами и с примерами кода.

Почему без move-семантики код тормозит?

Давайте посмотрим на классический пример, который мучил всех C++ программистов до 2011 года:

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

std::vector<std::string> createBigVector() {
    std::vector<std::string> vec(1000000, "Hello");
    return vec;  // Ужас! Копирование миллиона строк!
}

int main() {
    std::vector<std::string> data = createBigVector();
    std::cout << "Size: " << data.size() << std::endl;
    return 0;
}

Что здесь происходит? Функция createBigVector создаёт вектор, заполняет его данными и возвращает. Компилятор (без оптимизаций) делает следующее:

  1. Создаёт вектор внутри функции.
  2. Копирует ВЕСЬ вектор в возвращаемое значение.
  3. Уничтожает оригинальный вектор.
  4. В main копирует это значение в переменную data.

Итог: два полных копирования по миллиону строк. Это не просто медленно — это катастрофа для производительности.

Rvalue и Lvalue: два мира C++

Чтобы понять move-семантику, нужно разобраться с двумя фундаментальными понятиями: lvalue и rvalue.

  • Lvalue (left value) — объект, у которого есть адрес в памяти и имя. Вы можете взять его адрес (&obj). Пример: переменная int x = 5; — x это lvalue.
  • Rvalue (right value) — временное значение, которое не имеет имени и не может находиться слева от оператора присваивания. Пример: 5, x + 3, результат функции getTemp(). Вы не можете написать &(x + 3).

Раньше rvalue-ссылки (обозначаются &&) не существовало. Компилятор видел временный объект и говорил: «Ага, это rvalue, единственное, что я могу с ним сделать — скопировать его или уничтожить». Move-семантика дала компилятору третий вариант: украсть ресурсы у rvalue, потому что оно всё равно скоро умрёт.

Как работает std::move и rvalue-ссылки?

Функция std::move — это просто приведение типа. Она не перемещает данные физически, а лишь превращает lvalue в rvalue, давая компилятору разрешение «обворовать» объект.

Рассмотрим простой класс, который управляет динамической памятью:

#include <iostream>
#include <cstring>

class MyString {
private:
    char* data;
    size_t size;

public:
    // Конструктор (обычный)
    MyString(const char* str) {
        size = strlen(str) + 1;
        data = new char[size];
        memcpy(data, str, size);
        std::cout << "Constructor: copied <" << data << ">\n";
    }

    // Деструктор
    ~MyString() {
        delete[] data;
        std::cout << "Destructor\n";
    }

    // Копирующий конструктор
    MyString(const MyString& other) {
        size = other.size;
        data = new char[size];
        memcpy(data, other.data, size);
        std::cout << "Copy constructor: deep copy\n";
    }

    // Перемещающий конструктор (move constructor)
    MyString(MyString&& other) noexcept {
        // Просто крадём указатель у other
        data = other.data;
        size = other.size;
        // Обнуляем other, чтобы деструктор не удалил наши данные
        other.data = nullptr;
        other.size = 0;
        std::cout << "Move constructor: stole the data!\n";
    }

    // Перемещающий оператор присваивания
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] data;  // Освобождаем старые ресурсы
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
            std::cout << "Move assignment: stole the data!\n";
        }
        return *this;
    }

    void print() const {
        if (data) std::cout << data << std::endl;
        else std::cout << "(empty)" << std::endl;
    }
};

Теперь посмотрим, что произойдёт при использовании std::move:

int main() {
    MyString a("Hello, World!");
    MyString b = std::move(a);  // Вызовется move constructor

    std::cout << "a: "; a.print();  // a теперь пуст!
    std::cout << "b: "; b.print();  // b владеет данными

    return 0;
}

Вывод программы:

Constructor: copied <Hello, World!>
Move constructor: stole the data!
a: (empty)
b: Hello, World!
Destructor
Destructor

Обратите внимание: a после перемещения стал пустым. Это нормально — он находится в «пустом, но корректном» состоянии. Деструктор для a вызовется, но delete[] nullptr — безопасная операция.

Когда move-семантика работает автоматически?

Хорошая новость: в большинстве случаев вам не нужно писать std::move вручную. Компилятор сам определяет, когда можно применить перемещение. Это называется copy elision

Блоги

Рекомендаций по книгам