JavaRush /Курсы /C++ SELF /Move‑конструктор и move‑оператор присваивания

Move‑конструктор и move‑оператор присваивания

C++ SELF
45 уровень , 4 лекция
Открыта

1. deep copy иногда слишком дорогая роскошь

В прошлой теме мы уже разобрали ситуацию, когда объект вручную владеет ресурсом (например, массивом из new[]): деструктор обязан освобождать этот ресурс, а копирование «по умолчанию» для указателя почти гарантированно приводит к double‑free или use‑after‑free. Поэтому мы делали deep copy: копирующий конструктор и копирующее присваивание, чтобы каждый объект владел своим массивом.

Если вы только что написали глубокую копию и почувствовали гордость — это нормально, так и должно быть. Но дальше наступает реальная жизнь: вы начинаете писать код, где объект «путешествует» между функциями, временными переменными, возвращается из функций, кладётся в контейнеры. И внезапно выясняется, что вы тратите время на копирование там, где копировать вообще не нужно — ведь часто исходный объект всё равно больше не понадобится.

Представьте, что у вас есть единственный ключ от квартиры (это наш указатель data). Копирование — это как «пойти сделать дубликат ключа» (дорого и долго). А перемещение — это как «передать ключ из рук в руки» (быстро), при этом у того, кто отдал, ключа больше нет (и это важно!). В современном C++ перемещение — фундаментальная идея, поэтому термины вроде move assignment прямо фигурируют в материалах по стандарту и его терминологии.

2. Что значит «переместить объект»

Когда новички слышат «move», они часто думают: «ага, это просто копирование, но быстрее». Это почти правда… и именно поэтому опасно. Правильная мысль такая: мы переносим владение ресурсом, а не «делаем копию». После перемещения у нас остаётся один владелец ресурса, а у источника ресурс становится пустым (например, nullptr).

С точки зрения модели памяти это выглядит так:

flowchart LR
    A[Buffer a] -->|data = 0xAAA| M[(heap)]
    B[Buffer b] -->|data = nullptr| N[(нет ресурса)]

    A2[после move] -->|data = nullptr| X[(нет ресурса)]
    B2[получатель] -->|data = 0xAAA| M

Здесь ключевая идея: адрес 0xAAA никуда не «переезжает» физически — он остаётся там же, в heap. «Переезжает» право считать этот адрес своим и освобождать его в деструкторе.

3. Rvalue‑ссылка T&&: «можно разбирать на запчасти»

Сейчас будет момент, когда C++ слегка приподнимет бровь и скажет: «а теперь давайте аккуратно». Мы уже знаем T& — обычную ссылку, которая привязывается к существующему объекту и означает «это тот же самый объект, просто под другим именем». А вот T&& (rvalue‑ссылка) в контексте move‑операций обычно читается так: «передо мной объект‑источник, из которого разрешено забрать ресурс».

Важно держать в голове: T&& — это отдельный вид ссылки. У него есть тонкости в шаблонном коде (там оно иногда превращается в так называемую forwarding reference, и это отдельная вселенная). Мы это сегодня сознательно не трогаем, но сам факт «у T&& бывают нюансы» официально всплывает даже в обсуждениях про deduction guides.

Для нас сегодня достаточно простой практической модели:

  • const Buffer& — «дай посмотреть, но не трогать»
  • Buffer& — «дай поработать, возможно изменить»
  • Buffer&& — «дай забрать ресурс, ты всё равно уже уходишь»

4. Заготовка: Buffer с deep copy

Чтобы не писать «с нуля», будем считать, что у нас есть тип Buffer, который вручную владеет динамическим массивом int[], корректно копируется (deep copy) и корректно освобождается (RAII).

Ниже — компактная версия (без излишеств), от которой мы будем отталкиваться:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}

    ~Buffer() {
        delete[] data;
    }

    Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
        for (std::size_t i = 0; i < size; ++i) data[i] = other.data[i];
    }

    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;

        int* new_data = new int[other.size];
        for (std::size_t i = 0; i < other.size; ++i) new_data[i] = other.data[i];

        delete[] data;
        data = new_data;
        size = other.size;
        return *this;
    }
};

Эта версия уже корректна, но если вы начнёте активно передавать Buffer туда‑сюда, то будете платить за копирование почти везде: выделение памяти + цикл.

5. Move‑конструктор: «забрать указатель и обнулить источник»

Move‑конструктор нужен в тот момент, когда вы создаёте новый объект из временного или из объекта, который вы явно разрешили «разобрать». Его задача концептуально простая: взять у other его ресурсы, а other привести в такое состояние, чтобы его деструктор был безопасен. Это обычно называют «пустое состояние»: data == nullptr, size == 0.

Алгоритм move‑конструктора для нашего Buffer можно описать так:

flowchart TD
    A["Начало: Buffer(Buffer&& other)"] --> B[Скопировать size и data из other]
    B --> C[Обнулить other.size]
    C --> D["Обнулить other.data (nullptr)"]
    D --> E[Готово: теперь ресурс у *this*]

Реализация:

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }
};

Обратите внимание на характерную «наглость»: мы не выделяем память, не копируем элементы. Мы просто переписываем два поля и делаем источник пустым. Это и есть перенос владения.

6. std::move: это не «переместить», а «разрешить переместить»

Тут очень легко споткнуться об название. std::move(x) ничего не перемещает само по себе. Оно лишь делает так, чтобы компилятор воспринимал x как объект, который можно перемещать (то есть как rvalue). Реальное перемещение происходит, когда вызывается move‑конструктор или move‑оператор присваивания.

Небольшой пример «как это выглядит в коде»:

#include <utility>

int main() {
    Buffer a{5};
    Buffer b = std::move(a); // вызывает Buffer(Buffer&&)

    // после этого a.data == nullptr, a.size == 0 (по нашему дизайну)
}

Если вы забудете std::move, то компилятор будет считать, что вы хотите копировать (потому что a — именованная переменная, а значит lvalue), и попытается вызвать копирующий конструктор.

7. Move‑присваивание: «сначала освободи своё, потом забери чужое»

Move‑оператор присваивания (operator=(Buffer&&)) нужен, когда объект уже существует, и вы хотите заменить его ресурс на ресурс другого объекта‑источника. И тут появляется дополнительная обязанность: у левого объекта уже может быть свой массив, и его нужно корректно освободить, иначе утечка.

Важно также соблюдать порядок действий: сначала мы освобождаем старый ресурс слева (потому что он нам больше не нужен), потом забираем новый ресурс справа, а источник справа делаем пустым. При этом стоит помнить про ситуацию «самоприсваивания» (да, даже для move — теоретически возможно написать a = std::move(a);, и лучше не устраивать себе квест).

Схема move‑присваивания:

flowchart TD
    A["Начало: operator=(Buffer&& other)"] --> B{this == &other?}
    B -->|да| C[Вернуть *this]
    B -->|нет| D[Освободить текущий data]
    D --> E[Забрать other.size и other.data]
    E --> F[Сделать other пустым]
    F --> G[Вернуть *this]

Реализация (коротко и по делу):

#include <cstddef>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
    ~Buffer() { delete[] data; }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        if (this == &other) return *this;

        delete[] data;          // освобождаем старый ресурс слева
        size = other.size;      // забираем новый
        data = other.data;

        other.size = 0;         // обнуляем источник
        other.data = nullptr;
        return *this;
    }
};

Эта версия уже делает главное: предотвращает утечки слева и предотвращает double‑free справа.

8. Мини‑демонстрация в main: где реально «видно» перемещение

Когда изучаешь move‑семантику, мозг просит «пощупать руками». Самый простой способ — добавить маленькую диагностическую печать, чтобы видеть, какой конструктор/оператор вызвался. Да, в реальном проекте вы так делать не будете (или будете, но потом стыдливо удалите), зато для обучения это отлично работает.

Вот версия, где мы печатаем события:

#include <cstddef>
#include <iostream>
#include <utility>

struct Buffer {
    std::size_t size{};
    int* data{nullptr};

    explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {
        std::cout << "ctor(" << size << ")\n"; // ctor(3)
    }

    ~Buffer() {
        std::cout << "dtor(" << size << ")\n"; // dtor(...)
        delete[] data;
    }

    Buffer(Buffer&& other) : size(other.size), data(other.data) {
        std::cout << "move-ctor\n";            // move-ctor
        other.size = 0;
        other.data = nullptr;
    }

    Buffer& operator=(Buffer&& other) {
        std::cout << "move-assign\n";          // move-assign
        if (this == &other) return *this;

        delete[] data;
        size = other.size;
        data = other.data;

        other.size = 0;
        other.data = nullptr;
        return *this;
    }
};

int main() {
    Buffer a{3};
    Buffer b{1};

    b = std::move(a);
}

Вывод будет зависеть от порядка разрушения объектов в конце main, но ключевые строки move-assign и отсутствие «дорогой» deep copy вы увидите сразу.

9. Контракт moved‑from и сравнение copy vs move

Объект‑источник после move остаётся «живой»

Здесь хочется сказать «после std::move объект сломан» — и это распространённый миф. Он не «сломан», он просто больше не обязан хранить старые данные. Его состояние обычно называют «валидное, но не определённое по содержимому» (в бытовом смысле: «там может быть пусто, и это нормально»). В следующих лекциях мы будем формулировать это аккуратнее и строже, но уже сейчас полезно привыкнуть к мысли: moved‑from объект должен уметь безопасно разрушаться и принимать новое значение.

Практически, для нашего Buffer мы сами выбрали дизайн: moved‑from состояние — это (size = 0, data = nullptr). Это удобно, потому что:

  • delete[] nullptr; безопасен;
  • можно написать if (data == nullptr) как индикатор пустоты;
  • можно снова присвоить новый буфер, и всё будет нормально.

И да, тема «move‑операции часто помечают noexcept» действительно настолько важная, что вокруг неё есть отдельные обсуждения и решения даже для стандартных типов (например, для std::function и прочих), но детали noexcept мы разберём в следующей лекции, чтобы не смешивать две большие идеи в одну кашу.

Copy vs move: таблица

Чтобы закрепить различие, удобно держать перед глазами вот такую «человеческую» таблицу:

Операция Что делает Стоимость (обычно) Что с источником
Copy (deep copy) выделяет новый ресурс и копирует данные дорогая: new[] + цикл источник не меняется
Move передаёт владение ресурсом (указатель/размер) дешёвая: пару присваиваний источник становится пустым/«неважным»

Смысл в том, что move — это не «оптимизация ради скорости», а нормальная модель владения: если объект временный или его «можно отдавать», то копировать его содержимое — лишняя работа.

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

Ошибка №1: забрали other.data, но не обнулили other.data.
Это классический путь к double‑free: у получателя теперь правильный указатель, но у источника остался тот же самый адрес, и при выходе из области видимости оба деструктора попытаются освободить один и тот же массив. Лечится дисциплиной: после «кражи ресурса» источник приводится к пустому состоянию сразу в этом же методе.

Ошибка №2: в move‑присваивании забыли освободить старый ресурс слева.
Выглядит безобидно: «ну я же всё равно сейчас перепишу data». Но старый адрес при этом теряется, и массив превращается в утечку памяти. В move‑присваивании почти всегда есть строка вида delete[] data; перед тем, как вы забрали ресурс у other.

Ошибка №3: использовать объект после std::move(x), как будто он всё ещё «с данными».
Это логическая ошибка, а не обязательно аварийная: код может «как-то работать», но смысл уже потерян. После std::move(x) следует мысленно зачеркнуть x как «источник данных». Да, его можно присваивать заново и разрушать, но читать из него «как раньше» — плохая привычка, которая ломает программу в неожиданных местах.

Ошибка №4: писать перемещение, но делать внутри глубокую копию.
Иногда из лучших побуждений в move‑конструктор добавляют new[] и копирование элементов, потому что «ну вдруг так безопаснее». Получается странный гибрид: вы обещаете перемещение, а делаете копирование, только менее очевидное. Move‑операции ценны именно тем, что они не выделяют память и не копируют данные, а лишь передают владение.

Ошибка №5: забыть про this == &other в move‑присваивании.
Ситуация a = std::move(a); редкая, но возможная. Если не проверить самоприсваивание, можно случайно удалить собственный ресурс и потом «украсть» его же у себя — то есть украсть уже удалённое. Проверка if (this == &other) return *this; — простая страховка от очень неприятного класса багов.

1
Задача
C++ SELF, 45 уровень, 4 лекция
Недоступна
Переезд буфера
Переезд буфера
1
Задача
C++ SELF, 45 уровень, 4 лекция
Недоступна
Перехват владения
Перехват владения
1
Задача
C++ SELF, 45 уровень, 4 лекция
Недоступна
Единственный владелец
Единственный владелец
1
Задача
C++ SELF, 45 уровень, 4 лекция
Недоступна
Обмен без копий
Обмен без копий
1
Опрос
=default/=delete, 45 уровень, 4 лекция
Недоступен
=default/=delete
=default/=delete
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ