Профилировщик показывает, что главные триста миллисекунд приложения уходят на memcpy. Разработчик открывает горячую функцию и видит знакомую картину: вектор на миллион элементов передаётся по значению, строка на четверть мегабайта копируется на каждый вызов, а в сигнатурах нет ни одного амперсанда. В соседнем проекте наоборот: std::move обёрнуты и возвращаемые значения, и локальные переменные, и параметры, которые дешевле копировать. Обе команды страдают от одной причины: механику передачи владения не поняли, а выучили как рецепт. Ниже разбор того, как перемещение устроено на самом деле, сколько стоит копия большого объекта, где один пропущенный noexcept обращает всю выгоду в дым и в каких случаях копировать всё подряд дешевле и честнее.
Что функция move делает с объектом на самом деле и почему она ничего не перемещает
Главное разочарование новичка звучит так: std::move ничего не двигает. Это функция из заголовка utility, которая приводит ссылку на объект к типу rvalue-ссылки, по сути тот же static_cast<T&&>. Её результат не действие, а разрешение: теперь компилятор имеет право выбрать перемещающий конструктор вместо копирующего. Само перемещение выполняет конструктор или оператор присваивания класса, если они есть. Если их нет, std::move даёт тот же эффект, что и копия, и никто не сообщит об ошибке.
std::vector<int> a = build();
std::vector<int> b = std::move(a); // буфер переехал, a опустел
Объект, из которого переместили, остаётся живым и валидным. Для типов стандартной библиотеки состояние его не определено: вектор чаще всего окажется пуст, но рассчитывать на это нельзя, это свойство конкретной реализации, а не обещание стандарта. С перемещённым объектом разрешено всё, что не требует предпосылок: присвоить заново, разрушить, вызвать clear. Читать содержимое нельзя, пока что-то новое не присвоено.
Выбор между копией и перемещением делает перегрузка. Класс объявляет два конструктора, и категория выражения на входе решает, какой сработает:
Blob make();
Blob x = make(); // перемещение, временное значение
Blob y = x; // копия, имя это левое значение
Blob z = std::move(y); // перемещение, явное разрешение
Имя переменной это левое значение, даже если сам объект уже был перемещён: категория это свойство выражения, а не состояние объекта. Отсюда растёт корень недоразумений с перемещением.
Второе важное следствие: std::move от константного объекта разрешение не даёт. Константный объект двигать нельзя, перегрузка с rvalue-ссылкой не подходит, и компилятор молча выберет копирующий конструктор. Ошибка не ловится предупреждениями и вылезает только в профайлере, поэтому в ревью на const у параметров смотрят внимательно.
Сколько стоит копия большого объекта и что происходит при перемещении
Копия вектора это две вещи: аллокация нового блока и перенос всех байтов. Вектор на миллион целых чисел занимает 4 мегабайта, и копирование обходится в полный проход по памяти, миллион чтений и миллион записей. Перемещение того же вектора это присваивание трёх машинных слов: указателя на буфер, размера и границы ёмкости, плюс обнуление источника. Сложность падает с линейной до постоянной, скорость перестаёт зависеть от объёма.
std::vector<int> data(1'000'000); // буфер на 4 мегабайта
auto moved = std::move(data); // три машинных слова
auto copied = moved; // аллокация и перенос 4 мегабайт
Со строками история хитрее. Современные реализации держат короткие строки прямо внутри объекта: у libstdc++ это около 15 символов, у libc++ около 22. Короткая строка не имеет внешнего буфера, и перемещение сводится к копированию внутреннего массива: дёшево, но без бесплатного ускорения. Длинная строка живёт в куче, и перемещение переезжает указатель за три слова вместо мегабайтов копирования. То же касается больших деревьев: std::map с сотней тысяч узлов перемещается за постоянное время, корневой указатель, размер и аллокатор переезжают мгновенно, а узлы остаются на своих местах в куче.
std::string short_name = "index"; // лежит внутри объекта
std::string long_name(100'000, 'x'); // лежит в куче
Перемещение вектора из миллиона элементов занимает наносекунды. Копирование добавляет аллокацию и проход по памяти, который в горячем цикле стоит микросекунды. Одна и та же функция с копией и без копии на большом массиве различается в сотни раз, и это обычный эффект в парсерах и обработчиках байтов.
Как noexcept определяет поведение вектора при росте за пределы вместимости
Самое коварное место move-семантики прячется не в вызовах std::move, а в росте вектора. Когда вектор заполняется, выделяется новый блок, и все элементы переезжают из старого в новый. Стандарт обещает сильную гарантию исключений: если перенос бросит на середине, вектор обязан остаться в исходном состоянии. Отсюда правило: переносить элементы перемещением разрешено, только если перемещающий конструктор помечен noexcept. Иначе библиотека тихо выберет копирование, стандартный механизм выбора так и называется, move_if_noexcept.
struct Buffer {
std::unique_ptr<char[]> data;
size_t size;
Buffer(Buffer&& other) noexcept = default;
Buffer(const Buffer&) = delete;
};
Один пропущенный noexcept превращает экономию в дым. Код выглядит современно, ревью прошло, а вектор растёт копиями, и в профайлере это видно как внезапные memcpy на реаллокации. Совет простой: перемещающий конструктор помечается noexcept всегда, и в ревью это правило проверяют так же механически, как отсутствие голого new. Проверить поведение конкретного класса легко: вырастить вектор таких объектов до переполнения и посмотреть в профайлере, были ли копии. Оценку можно сделать и на бумаге: вектор из ста тысяч строк при удвоении без noexcept копирует сто тысяч строк, с noexcept переносит сто тысяч указателей по три слова.
Передача параметров в функции по значению и по ссылке на практике
Три канонических способа передачи закрывают почти все ситуации. Для чтения подойдёт константная ссылка, лишних копий нет. Для владения, когда функция забирает объект в поле или контейнер, подойдёт передача по значению с перемещением внутри. Для пересылки дальше, когда параметр пришёл снаружи и уходит в хранилище, подойдёт шаблон с пересылающей ссылкой и std::forward.
class Registry {
std::vector<std::string> names_;
public:
void add(std::string name) {
names_.push_back(std::move(name));
}
};
Параметр name копируется один раз, если вызывающий передал переменную, и не копируется вовсе, если передали временную строку или выражение std::move. Внутри push_back значение перемещается. Максимум одна копия при любом сценарии, и это лучше, чем копия в параметре-ссылке и копия в хранилище, как выходит у варианта с const std::string& и присваиванием внутри.
template <typename T, typename... Args>
T& emplace(Args&&... args) {
return storage.emplace_back(std::forward<Args>(args)...);
}
Ссылка на шаблонный параметр Args&& это не rvalue-ссылка, а пересылающая ссылка: она связывается и с левыми, и с правыми значениями и запоминает категорию. std::forward восстанавливает её при передаче дальше. Классическая пара перегрузок с const T& и T&& тоже работает, но дублирует тело функции и усложняет поддержку, у приёмников по значению те же затраты при вдвое меньшем количестве строк.
Сигнатура-приёмник по значению ещё и документирует владение типом, без комментариев: функция назвала параметр значением, значит забирает его навсегда.
Правило пяти и классы которые умеют только перемещаться
Класс, владеющий ресурсом, обязан определить пять операций: деструктор, конструктор копии, присваивание копии, перемещающий конструктор и перемещающее присваивание. Правило пяти из Core Guidelines прямо говорит: определил или удалил одну из них, определи или удаляй все.
class Session {
int id_ = 0;
std::unique_ptr<Channel> channel_;
public:
Session(const Session&) = delete;
Session& operator=(const Session&) = delete;
Session(Session&& other) noexcept
: id_{std::exchange(other.id_, 0)}
, channel_{std::move(other.channel_)} {}
Session& operator=(Session&& other) noexcept {
if (this != &other) {
id_ = std::exchange(other.id_, 0);
channel_ = std::move(other.channel_);
}
return *this;
}
};
std::exchange возвращает старое значение и присваивает новое, для номера это одна строка вместо двух. Проверка на самоприсваивание в перемещающем присваивании не формальность: присваивание объекта самому себе оставило бы его пустым.
У классов, чьи поля владеют ресурсами сами, через unique_ptr, vector и string, пять операций генерируются правильно без единой ручной строки. Это правило нуля: обёртки в полях, никаких операций вручную. Перемещать без права копирования умеют целые семейства типов: unique_ptr, std::thread, std::jthread, файловые потоки. Фабрики, возвращающие владение, существуют благодаря этому: до C++11 передать монопольный ресурс через границу функции было нельзя, и код прятал его в выходные параметры.
Половину проблем с правилом пяти ловит компилятор: флаги -Wdeprecated-copy у GCC и Clang предупреждают, когда у класса с деструктором неявно генерируется копирование.
Частые ошибки применения move-семантики в прикладном коде
Список ошибок, которые находят почти в каждом первом проекте, выглядит так:
- чтение перемещённого объекта без нового присваивания, состояние валидно, а содержимое неизвестно;
- вызов std::move от константного объекта, который молча превращается в копию;
- возврат std::move от локальной переменной, отключающий оптимизацию возврата значения;
- перемещение в операторе присваивания без проверки на самоприсваивание;
- отсутствие noexcept у перемещающего конструктора, из-за чего вектор растёт копиями;
- ожидание ускорения от перемещения короткой строки, у которой нет внешнего буфера;
- раздача одного объекта двум владельцам через повторное перемещение из одного источника.
Хорошая новость в том, что все семь ошибок ловятся текстовым поиском по коду и внимательным ревью, ни одна не требует перепроектирования. Плохая: список обновляется, и восьмым пунктом регулярно становится std::move внутри цикла от одного и того же объекта, который при второй итерации уже пуст.
Часть списка закрывается автоматикой. clang-tidy знает правило bugprone-use-after-move для чтения перемещённого объекта и performance-move-const-arg для бессмысленного перемещения константы. Час настройки в CI закрывает половину пунктов.
Гарантированное исключение копирования и случаи когда копирование остаётся уместным
Стандарт C++17 закрыл старый спор о возврате значений: копирование из выражения-времянки гарантированно исключено. Функция, возвращающая std::vector<int>(n) напрямую, создаёт объект прямо на месте назначения, без копии и без перемещения. Для именованных локальных переменных оптимизация возврата стандартом не гарантирована, но реализуется всеми основными компиляторами, и оборачивание результата в std::move её отключает, оставляя лишнее перемещение. Правило простое: return local; без обёрток.
std::vector<int> make(int n) {
return std::vector<int>(n); // без копий и без перемещений
}
Есть и мирная зона для копирования. Маленькие дешёвые объекты, числа, точки, span, string_view, копируются за несколько машинных слов, и городить перемещение ради них бессмысленно. Копия остаётся правильным выбором там, где нужны две независимые версии значения: срез состояния, буфер для записи в другой поток, значение для конкурентного чтения. Перемещение изменяет источник, и там, где обе копии должны остаться живыми, перемещать нельзя.
Отдельная тонкость для собеседований: перемещение не гарантирует постоянного времени для любого типа, оно лишь передаёт право вырезать внутренности. std::array из 4096 байт хранит значения прямо в объекте, и его перемещение равно копии, внешнего буфера нет. Обещание быстрого перемещения даёт структура с внешним хранением: вектор, строка в куче, дерево.
Копировать всё подряд ошибка там, где объект большой, а источник больше не нужен. Двигать всё подряд ошибка там, где объект дешёвый или источник ещё пригодится. Рабочее правило умещается в три строки: большие буферы перемещать, мелкие значения копировать, параметры-приёмники принимать по значению и перемещать внутрь. Механика владения не загадка, а один раз понятая схема про то, кому принадлежат байты. После этого понимания std::move превращается из модного украшения в точный инструмент с ясной ценой.