Move-семантика в C++: где она молча не срабатывает
Вы написали std::move, а копирование всё равно произошло. Разбираем случаи, когда перемещение тихо превращается в копию, и как это увидеть на компиляторе, а не в профайлере под нагрузкой.

Вы возвращаете большой std::vector из функции через return std::move(v) и ждёте перемещения. А получаете копию — потому что std::move отключил гарантированную оптимизацию возврата. Или пишете класс с деструктором, вставляете его в вектор, и при реаллокации вектор копирует элементы вместо перемещения, хотя move-конструктор у вас есть. Оба случая компилируются без единого предупреждения. Разберём, где move-семантика C++ не срабатывает по правилам языка, как это заметить на этапе компиляции и что делать. Материал ориентируется на C++17 и новее, отдельные моменты касаются C++11/14.
Быстрая калибровка: что вообще перемещается
Перемещение — это не магия компилятора, а вызов move-конструктора или move-присваивания вместо копирующих. Компилятор выбирает move-версию, когда аргумент — rvalue: временный объект или то, что вы явно привели к rvalue через std::move. Если подходящей move-версии нет или язык запрещает считать выражение rvalue, вызывается копирование. Молча.
Дальше — конкретные ситуации, где вы ждёте перемещения, а его не будет.
1. std::move на возврате мешает copy elision
Самая частая ошибка. Компилятор умеет строить возвращаемый объект прямо в месте вызова — это NRVO (named return value optimization) и гарантированный elision для временных. Когда вы оборачиваете локальную переменную в std::move при возврате, вы превращаете именованный объект в rvalue-ссылку и запрещаете NRVO. В лучшем случае получите перемещение вместо elision, в худшем — с типами вроде std::array реального выигрыша нет, а читаемость страдает.
#include <vector>
std::vector<int> makeGood() {
std::vector<int> result(1000);
return result; // NRVO: объект строится сразу в вызывающем коде
}
std::vector<int> makeBad() {
std::vector<int> result(1000);
return std::move(result); // NRVO запрещён, будет вызван move-конструктор
}
Правило простое: возвращаете локальную переменную того же типа, что и возвращаемое значение — пишите return result; без обёрток. Компилятор сам выберет elision или перемещение. GCC и Clang с флагом -Wpessimizing-move выдадут предупреждение на makeBad — включите его.
Когда std::move на возврате всё-таки нужен
Если тип возвращаемого значения отличается от типа переменной или вы возвращаете член объекта/параметр-по-ссылке, elision невозможен, и std::move оправдан. Пример — возврат std::unique_ptr<Base> из переменной типа std::unique_ptr<Derived>: здесь без std::move будет ошибка компиляции, потому что копирование unique_ptr запрещено.
2. const ломает перемещение полностью
Move-конструктор принимает T&& — неконстантную rvalue-ссылку. Константный объект под std::move даёт const T&&, а такая ссылка не связывается с параметром move-конструктора. Зато отлично связывается с параметром копирующего — const T&. Результат: std::move от const-объекта тихо вызывает копирование.
#include <string>
#include <vector>
void append(std::vector<std::string>& dst, const std::string& src) {
// src — const, move не сработает, будет копия
dst.push_back(std::move(src));
}
Тут нет ошибки компиляции и нет предупреждения по умолчанию — код корректен, просто медленнее ожидаемого. Ловится только чтением: если переменная const, std::move от неё бессмысленен. Не делайте локальные переменные const, если собираетесь их перемещать.
std::move ничего не перемещает. Это приведение типа к rvalue-ссылке. Будет ли реальное перемещение — решает перегрузка, которую после этого выберет компилятор. const выбирает копирующую.
3. Отсутствие noexcept у move-конструктора
Контейнеры стандартной библиотеки при реаллокации дают строгую гарантию безопасности исключений. std::vector при росте переместит элементы в новый буфер только если их move-конструктор помечен noexcept. Иначе он их скопирует — чтобы при исключении в середине операции старый буфер остался цел.
struct WithLog {
std::string data;
WithLog(WithLog&& other) /* нет noexcept */ : data(std::move(other.data)) {}
WithLog& operator=(WithLog&&) = default;
};
// std::vector<WithLog> при реаллокации будет КОПИРОВАТЬ элементы,
// потому что move-конструктор может кинуть исключение
Механизм внутри — вызов std::move_if_noexcept: он возвращает rvalue-ссылку, только если move не бросает или копирование недоступно. Вы написали move-конструктор, он вызывается при явном std::move, но при push_back в растущий вектор — нет. Помечайте move-операции noexcept, если они действительно не бросают, а компилятор не сгенерировал их за вас.
Проверить можно во время компиляции:
#include <type_traits>
static_assert(std::is_nothrow_move_constructible_v<WithLog>,
"move-конструктор не noexcept — vector будет копировать");
4. Правило пяти: свой деструктор убивает генерируемый move
Компилятор генерирует move-конструктор и move-присваивание автоматически — но только если вы не объявили деструктор, копирующий конструктор или копирующее присваивание. Объявили любое из них — генерация move-операций отменяется. Класс остаётся копируемым (копирующие операции ещё генерируются для обратной совместимости, хотя это deprecated), и любой std::move от такого объекта уходит в копирование.
struct Handle {
int* buffer;
~Handle() { delete[] buffer; } // объявили деструктор →
// move-операции НЕ сгенерированы
// теперь Handle только копируется, и это ещё и опасно (double free)
};
Это одновременно и производительность, и корректность: для класса, владеющего ресурсом, автоматическое копирование ведёт к двойному освобождению. Следуйте правилу пяти: объявили один из специальных методов — осознанно объявите остальные, включая = default или = delete для move.
Сводка: где ждёте move, а получаете копию
| Ситуация | Что происходит | Как поймать |
|---|---|---|
return std::move(local) | Запрет NRVO, в лучшем случае лишний move | -Wpessimizing-move |
std::move от const-объекта | Тихая копия через const T& | Ревью: const + move бессмысленны |
move-конструктор без noexcept | vector копирует при реаллокации | is_nothrow_move_constructible_v |
| Свой деструктор без правила пяти | move-операции не генерируются, идёт копия | is_move_constructible + ревью |
Перемещение по цепочке без && | Именованный параметр — lvalue, нужен явный move | Ревью функций-обёрток |
Про последнюю строку: rvalue-ссылка сама по себе — lvalue
Параметр функции типа T&& внутри тела — это именованная переменная, а значит lvalue. Если вы принимаете T&& и передаёте дальше без повторного std::move, вызовется копирование. Для шаблонных forwarding-ссылок то же самое решается через std::forward. Инвалидация перемещённого объекта и forwarding-ссылки в шаблонах — отдельные темы, здесь только фиксируем сам факт.
Как это диагностировать, а не гадать
- Включите
-Wpessimizing-moveи-Wredundant-moveв GCC/Clang. Они ловят лишний и вредныйstd::moveна возврате. - Для критичных к перемещению типов поставьте
static_assert(std::is_nothrow_move_constructible_v<T>)рядом с определением класса. - Считаете, что объект переместился — залогируйте вызовы конструкторов в тестовом типе или посмотрите ассемблер компилятора на godbolt.org: копирующий конструктор в выводе выдаст себя.
- При ревью помечайте связку
const+std::moveи любойreturn std::move(local)как замечание.
Что делать дальше
Пройдитесь по типам, которые вы кладёте в std::vector и другие контейнеры, и проверьте их через is_nothrow_move_constructible_v — это одна из самых дорогих молчаливых копий под нагрузкой при росте контейнера. Затем настройте предупреждения о лишнем move в CI, чтобы новые случаи ловились автоматически. Следующая тема для отдельного разбора — состояние объекта после перемещения: стандарт гарантирует лишь «валидное, но неопределённое» состояние, и полагаться на конкретное значение перемещённого объекта нельзя.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: cppreference: std::move и правила перемещения, cppreference: copy elision
Частые вопросы
У меня C++11, а не C++17. Отличается ли поведение с NRVO?
Почему компилятор не предупреждает про copy вместо move от const-объекта?
Я добавил noexcept к move-конструктору, а vector всё равно копирует. Почему?
Правило пяти обязательно, если мне нужен только деструктор?
Как под нагрузкой понять, что где-то происходит лишнее копирование вместо перемещения?
std::move на примитивных типах вроде int что-то даёт?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.