Профилировщик показывает, что главные триста миллисекунд приложения уходят на 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-семантики в прикладном коде

Список ошибок, которые находят почти в каждом первом проекте, выглядит так:

  1. чтение перемещённого объекта без нового присваивания, состояние валидно, а содержимое неизвестно;
  2. вызов std::move от константного объекта, который молча превращается в копию;
  3. возврат std::move от локальной переменной, отключающий оптимизацию возврата значения;
  4. перемещение в операторе присваивания без проверки на самоприсваивание;
  5. отсутствие noexcept у перемещающего конструктора, из-за чего вектор растёт копиями;
  6. ожидание ускорения от перемещения короткой строки, у которой нет внешнего буфера;
  7. раздача одного объекта двум владельцам через повторное перемещение из одного источника.

Хорошая новость в том, что все семь ошибок ловятся текстовым поиском по коду и внимательным ревью, ни одна не требует перепроектирования. Плохая: список обновляется, и восьмым пунктом регулярно становится 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 превращается из модного украшения в точный инструмент с ясной ценой.