JavaRush /Курсы /C++ SELF /Перемещение владения — std::move

Перемещение владения — std::move

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

1. Почему std::unique_ptr не копируется

Когда вы впервые видите std::unique_ptr, очень хочется обращаться с ним как с обычным указателем: «ну адрес же можно копировать, значит и unique_ptr можно». На уровне адреса — да, копировать можно хоть сто раз. Но проблема не в адресе, а в ответственности: кто обязан освободить память?

Если два объекта считают себя владельцами одного и того же ресурса, то в какой-то момент один из них освободит память, а второй позже попытается освободить её ещё раз. Это классический double-free — тот самый баг, который может проявиться не сразу, а «в пятницу вечером на проде» (разработчики шутят, что это особый жанр хоррора). Поэтому для unique_ptr копирование — не просто «не рекомендовано», а логически запрещено.

Как C++ запрещает копирование

std::unique_ptr<T> устроен так, что его нельзя копировать: у него удалены (deleted) копирующий конструктор и копирующее присваивание. Это значит: вы даже не сможете случайно «протащить» копию через присваивание — компилятор остановит вас на этапе сборки.

И это отличная новость: лучше получить ошибку компиляции, чем «успешно» собрать программу, которая иногда падает, иногда портит память, а иногда делает вид, что всё хорошо. С unique_ptr компилятор выступает в роли строгого охранника: «второй владелец не пройдёт».

#include <memory>

int main() {
    auto a = std::make_unique<int>(10);

    auto b = a; //  ошибка компиляции: копирование запрещено
}

Здесь auto b = a выглядит безобидно, но по смыслу означает «теперь у ресурса два владельца». Поэтому компилятор скажет что-то в духе: use of deleted function (формулировка зависит от компилятора).

2. Перемещение владения и std::move

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

Ответ: перемещение (move). Перемещение — это не «создать ещё одну копию владельца», а «передать владение от одного владельца к другому». Метафора простая: есть ключи от квартиры (ресурс) и есть человек, который ими владеет (unique_ptr). Перемещение — это передать ключи другому человеку. После этого у первого ключей больше нет, но сам человек никуда не исчез.

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

flowchart LR
    A["a: unique_ptr (владеет)"] -->|"std::move(a)"| B["b: unique_ptr (становится владельцем)"]
    A2["a: unique_ptr (пустой nullptr)"] --- B

И в коде это выглядит так:

#include <iostream>
#include <memory>
#include <utility>

int main() {
    auto a = std::make_unique<int>(10);
    auto b = std::move(a); // перенос владения

    std::cout << std::boolalpha;
    std::cout << (a == nullptr) << '\n'; // true
    std::cout << *b << '\n';             // 10
}

Обратите внимание: a после переноса остаётся валидным объектом, но обычно становится пустым (nullptr). Это нормально и ожидаемо.

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

Название std::move — один из самых успешных источников путаницы в истории C++. Новички читают «move» и думают: «ага, это функция, которая прямо сейчас переносит данные». Но нет: std::move(x) по смыслу ближе к фразе «я разрешаю относиться к x как к объекту, который можно переместить».

Технически std::move — это преобразование, которое превращает выражение в rvalue (грубо говоря, «временное/отдаваемое значение»). Сам перенос делает не std::move, а тот код, который получает результат std::move(x) и умеет перемещать.

Плохой, но очень поучительный пример:

#include <memory>
#include <utility>

int main() {
    auto p = std::make_unique<int>(5);

    std::move(p); // само по себе ничего не меняет: результата никто не забрал
    // p всё ещё владеет ресурсом
}

Здесь std::move(p); — практически бессмысленная строка. Она лишь «попросила» компилятор рассматривать p как отдаваемый, но никто это отдаваемое значение не принял.

А вот правильный смысл std::move виден здесь:

#include <memory>
#include <utility>

int main() {
    auto p = std::make_unique<int>(5);
    auto q = std::move(p); // q принял владение
}

Небольшой занудный факт

Стандартная библиотека очень внимательно описывает поведение перемещения. Настолько внимательно, что в редакторских отчётах и списках исправлений стандарта встречаются отдельные пункты про корректность постусловий move-присваивания unique_ptr, а также про гарантии noexcept у перемещающих операций.

Это хороший сигнал: тема не «магическая», а строго специфицированная.

Перемещение при присваивании

Перемещение бывает не только «при создании новой переменной» (auto b = std::move(a);), но и при присваивании уже существующей. И тут появляется важная деталь: а что делать с тем ресурсом, которым левый unique_ptr владел раньше?

Ответ: левый unique_ptr сначала освобождает свой старый ресурс, а потом забирает ресурс у правого. То есть операция безопасная и самодостаточная: память не «теряется» и не начинает принадлежать двум владельцам.

#include <iostream>
#include <memory>
#include <utility>

int main() {
    auto a = std::make_unique<int>(1);
    auto b = std::make_unique<int>(2);

    b = std::move(a); // b теперь владеет тем, чем владел a

    std::cout << std::boolalpha;
    std::cout << (a == nullptr) << '\n'; // true
    std::cout << *b << '\n';             // 1
}

В этой ситуации «старый» ресурс b (где было 2) освобождён автоматически внутри move-присваивания. Вы не пишете delete, не вызываете «очистку» руками — RAII продолжает работать.

Состояние после перемещения

После std::move люди часто нервничают: «а переменная теперь сломана?» Она не сломана. Она валидна, её можно уничтожить, можно присвоить ей новое значение, можно сравнить с nullptr. Но нельзя делать одно: нельзя разыменовывать, пока не убедились, что она не пустая.

Для unique_ptr нормальный, ожидаемый moved-from-статус — это nullptr. Это не «ошибка», а честный сигнал: владение ушло.

Вот мини-табличка «можно/нельзя» для moved-from unique_ptr:

Действие Можно? Почему
if (p) / p == nullptr
да проверка состояния безопасна
присвоить новый ресурс да unique_ptr снова станет владельцем
уничтожить (p выходит из scope) да уничтожение пустого владельца безопасно
*p
или
p->field
нет (без проверки) p может быть nullptr

Пример «правильной дисциплины»:

#include <iostream>
#include <memory>
#include <utility>

int main() {
    auto p = std::make_unique<int>(7);
    auto q = std::move(p);

    if (p) {
        std::cout << *p << '\n';
    } else {
        std::cout << "p is empty\n"; // p is empty
    }

    std::cout << *q << '\n'; // 7
}

3. Практика: черновик задачи

Чтобы перемещение владения не осталось абстрактной магией, давайте встроим его в маленький сценарий. Представим, что у нас есть консольное приложение-органайзер: мы храним задачи в std::vector<Task>. Иногда пользователь «набивает» черновик задачи, но ещё не добавляет его в список. Черновик либо будет принят, либо выброшен. Это прямо просится на модель единоличного владения.

Сделаем Task и переменную draft, которая либо пустая, либо владеет черновиком:

#include <memory>
#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task> tasks;
    std::unique_ptr<Task> draft; // черновик: либо есть, либо нет
}

Теперь добавим создание черновика. Мы ещё не обсуждали «идеальные» сигнатуры функций для unique_ptr (это будет следующая лекция), поэтому сделаем максимально прямолинейно: создаём объект и переносим его в draft.

#include <iostream>
#include <memory>
#include <string>
#include <utility>

struct Task {
    int id{};
    std::string title;
};

int main() {
    auto tmp = std::make_unique<Task>(Task{1, "Read about std::move"});
    std::unique_ptr<Task> draft = std::move(tmp);

    std::cout << (tmp == nullptr) << '\n'; // 1 (true)
    std::cout << draft->title << '\n';     // Read about std::move
}

То есть tmp был «временным владельцем», а потом отдал владение черновику. В реальном коде «временный владелец» может появляться не потому, что вам так захотелось, а потому что вы получили unique_ptr из другого места и хотите передать дальше.

Теперь представим, что мы хотим «принять черновик»: добавить задачу в tasks. Поскольку tasks хранит значения Task, а не владение через указатели, мы просто копируем/перемещаем сам Task (объект) в вектор. Для новичка здесь полезно видеть разницу: unique_ptr управляет временем жизни, а вектор хранит сами данные.

#include <memory>
#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
};

int main() {
    std::vector<Task> tasks;
    auto draft = std::make_unique<Task>(Task{1, "Finish lecture 212"});

    tasks.push_back(*draft); // добавляем сам Task по значению
    draft = nullptr;         // черновик “свободен” (объект был в draft и будет удалён)
}

Строка draft = nullptr; освободит ресурс у владельца. Мы намеренно используем присваивание nullptr, потому что более продвинутые операции управления (reset, release, get) — тема следующих лекций дня.

4. Типичные ошибки при std::move и unique_ptr

Когда тема «перемещения» щёлкает в голове, возникает новый соблазн: начать ставить std::move везде, как будто это приправа «универсальная вкуснота». Но std::move — не приправа, а предупреждающий знак: «я сейчас отдам это значение». Поэтому ошибок здесь два типа: либо вы забыли std::move там, где он нужен, либо поставили его там, где он ломает логику.

Ошибка №1: попытка копировать unique_ptr “по привычке”.
Обычно это выглядит как auto b = a; или b = a;. Компилятор ругнётся — и это хорошо. Лечится просто: если вы действительно хотите передать владение, используйте std::move(a). Если владение передавать не хотите, значит вам вообще не нужен unique_ptr в этой точке — нужен невладеющий доступ (об этом поговорим дальше по плану дня).

Ошибка №2: использование переменной после std::move, как будто ничего не произошло.
После auto b = std::move(a); переменная a очень часто становится nullptr. Разыменовать *a или a->field после этого — почти гарантированный путь к падению. Привычка должна быть такой: увидели std::move(a) — мысленно зачеркнули a как владельца и используйте только после if (a) или после присваивания нового ресурса.

Ошибка №3: вера в то, что std::move сам освобождает память.
std::move ничего не удаляет и не вызывает delete. Он только «помечает» выражение как отдаваемое. Освобождение памяти произойдёт либо внутри move-операции unique_ptr (когда один владелец заменяет ресурс), либо когда владелец уничтожится (выйдет из scope), либо когда вы явно сделаете его пустым (например, p = nullptr).

Ошибка №4: забыли подключить <utility> и удивились, что std::move “не существует”.
Это такая мелкая, но частая штука: вы подключили <memory>, потому что нужен unique_ptr, и логично решили, что там же лежит std::move. Но std::move живёт в <utility>. Если компилятор ругается на неизвестный std::move, не надо спорить с ним — просто подключите нужный заголовок.

Ошибка №5: попытка “переместить из const”.
Если у вас const std::unique_ptr<T> p, то это объект, который по контракту нельзя менять. А перемещение unique_ptr меняет источник (обычно делает его пустым). Поэтому «переместить из const-владельца» нельзя. Обычно это проявляется как неожиданная ошибка компиляции в месте, где вы написали std::move(p), а оно не компилируется. Смысл тут хороший: нельзя обещать «не изменю» и одновременно «отдам владение».

1
Задача
C++ SELF, 42 уровень, 4 лекция
Недоступна
Переезд владения
Переезд владения
1
Задача
C++ SELF, 42 уровень, 4 лекция
Недоступна
Холостой move
Холостой move
1
Задача
C++ SELF, 42 уровень, 4 лекция
Недоступна
Обмен ключами
Обмен ключами
1
Задача
C++ SELF, 42 уровень, 4 лекция
Недоступна
Путь черновика
Путь черновика
1
Опрос
RAII и <code><span class="text-user">unique_ptr</span></code>, 42 уровень, 4 лекция
Недоступен
RAII и unique_ptr
RAII и unique_ptr
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ