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:
| Действие | Можно? | Почему |
|---|---|---|
|
да | проверка состояния безопасна |
| присвоить новый ресурс | да | unique_ptr снова станет владельцем |
| уничтожить (p выходит из scope) | да | уничтожение пустого владельца безопасно |
или |
нет (без проверки) | 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), а оно не компилируется. Смысл тут хороший: нельзя обещать «не изменю» и одновременно «отдам владение».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ