Move-семантика в C++ на практике: где даёт выигрыш, а где нет
std::move — не «ускорь тут». Разбираем, когда move реально экономит аллокации, а когда компилятор всё делает сам, и на каких приёмах теряется вся выгода.

Функция возвращает std::vector<std::string> на миллион элементов, и кто-то дописывает return std::move(result); «для скорости». В большинстве компиляторов это не ускоряет, а замедляет: std::move в return глушит копирование при возврате (NRVO) и превращает бесплатный возврат в реальный вызов move-конструктора. Move-семантика экономит там, где иначе была бы глубокая копия с аллокацией, но эти места не совпадают с интуицией «где хочется быстрее». Разбираем на C++17 и новее, где move даёт измеримый выигрыш, где компилятор справляется сам и где привычные приёмы съедают всю выгоду.
Что вообще перемещается
Move выигрывает у copy только у типов, которые владеют ресурсом за пределами объекта: std::vector, std::string, std::unique_ptr, дескрипторы файлов, соединения. Move-конструктор такого типа переставляет указатель на буфер и обнуляет источник — это несколько присваиваний вместо аллокации и копирования N байт.
Для типов без внешнего ресурса move физически равен copy. Перемещение int, std::array<int, 100> или структуры из трёх double копирует те же байты. Никакого выигрыша нет, и попытка «переместить» их через std::move не делает ничего полезного.
std::move не перемещает. Это приведение типа к rvalue-ссылке. Оно лишь разрешает компилятору выбрать move-конструктор, если тот существует. Сам факт написать std::move не гарантирует ни move, ни ускорения.
Что такое rvalue-ссылка*
* rvalue-ссылка (T&&) — ссылка, которую можно привязать к временному объекту или к тому, что явно помечено std::move. Перегрузка по T&& позволяет «украсть» внутренности источника, потому что вызывающая сторона обещала, что этот объект больше не нужен в прежнем состоянии.
Приём первый: return без std::move
Компилятор при возврате локальной переменной применяет copy elision — строит объект сразу в месте вызова. Для named return value (NRVO) это разрешено, а с C++17 elision временных объектов стал обязательным по стандарту.
// C++17. Никакого std::move в return.
std::vector<std::string> loadNames() {
std::vector<std::string> names;
names.reserve(1000);
// ... заполняем names
return names; // NRVO: объект строится прямо у вызывающего
}
Если написать return std::move(names);, вы принудительно приводите к rvalue, и NRVO больше не применяется — стандарт запрещает elision, когда возвращается rvalue-ссылка. В лучшем случае получите move-конструктор вместо elision, в худшем — предупреждение -Wpessimizing-move у GCC и Clang.
Правило: возвращаете локальную переменную своим типом — пишите просто return names;. std::move в return нужен только когда возвращаемый тип отличается от типа переменной (например, возврат base из derived) или когда возвращается параметр-по-значению.
Приём второй: приём аргументов
Здесь чаще всего теряют выигрыш. Три способа принять объект, который функция сохранит у себя:
class Storage {
std::vector<int> data_;
public:
// Вариант A: const-ссылка + копия внутри
void setByConstRef(const std::vector<int>& v) { data_ = v; }
// Вариант B: приём по значению + move внутрь
void setByValue(std::vector<int> v) { data_ = std::move(v); }
// Вариант C: две перегрузки
void setC(const std::vector<int>& v) { data_ = v; }
void setC(std::vector<int>&& v) { data_ = std::move(v); }
};
Вариант A всегда копирует, даже если вызывающий передал временный объект. Вариант B (приём по значению) — компромисс: при передаче lvalue происходит одна копия в параметр плюс дешёвый move внутрь, при передаче rvalue — один move в параметр плюс move внутрь. Вариант C оптимален по числу операций, но это две функции, а при нескольких аргументах комбинаторика перегрузок взрывается.
Для сеттеров с одним аргументом «принять по значению и std::move внутрь» — рабочий дефолт: одна функция, поведение предсказуемо. Для горячего кода с большими объектами и частой передачей lvalue вариант C убирает лишнюю копию.
Считаем операции
| Способ приёма | Передан lvalue | Передан rvalue |
|---|---|---|
| A: const& + копия | 1 copy | 1 copy |
| B: by value + move | 1 copy + 1 move | 2 move |
| C: две перегрузки | 1 copy | 1 move |
move для std::vector — это перестановка трёх указателей, а не копирование буфера. Поэтому лишний move в варианте B почти бесплатен, а вот лишняя copy в варианте A на большом векторе — это аллокация и полное копирование данных.
Приём третий: move в контейнере ломает состояние источника
После std::move объект остаётся в «валидном, но неопределённом» состоянии. Для стандартных контейнеров это обычно пустое состояние, но стандарт не гарантирует конкретное значение — гарантирует лишь, что объект можно безопасно разрушить и присвоить ему заново.
std::string source = "важные данные";
std::string dest = std::move(source);
// source теперь в неопределённом состоянии.
// Читать source.size() или source[0] можно (UB не будет),
// но полагаться на конкретное значение нельзя.
Типичная ошибка — переместить из объекта в цикле и продолжить его использовать:
// Ошибка: item перемещён, но используется дальше
for (auto& item : items) {
sink.push_back(std::move(item));
log(item.name()); // item уже опустошён — прочитаете мусор или пустоту
}
Читайте нужные поля до перемещения либо не перемещайте, если объект нужен целиком после.
Приём четвёртый: правило пяти и noexcept на move
Если вы пишете класс с move-конструктором вручную, помечайте его noexcept. Это не косметика: std::vector при перевыделении памяти выбирает move только если move-конструктор элемента noexcept. Иначе, чтобы сохранить строгую гарантию исключений, вектор копирует элементы вместо перемещения — и вся ваша move-семантика в этом контейнере не работает.
class Buffer {
char* data_ = nullptr;
size_t size_ = 0;
public:
// move-конструктор помечен noexcept -> vector будет перемещать
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
~Buffer() { delete[] data_; }
// ... остальные члены правила пяти
};
Проверить, что тип перемещается без исключений, можно на этапе компиляции: static_assert(std::is_nothrow_move_constructible_v<Buffer>);. Если ассерт падает — vector с этим типом молча копирует.
Когда move не нужен вовсе
- Возврат локальной переменной — работает NRVO,
std::moveтолько мешает. - Тривиально копируемые типы (числа, POD-структуры) — move равен copy.
- Объект нужен после «перемещения» — тогда это не move, а copy, и
std::moveздесь опасен. - const-объект —
std::moveотconst Tдаётconst T&&, к которому move-конструктор не привяжется, вызовется copy. Молча и без ошибки.
Как убедиться, что move действительно случился
Интуиции недостаточно — проверяйте инструментами, а не догадками:
- Соберите с
-Wpessimizing-move -Wredundant-move(GCC/Clang). Первое ловитstd::move, убивающий elision, второе — избыточныйstd::moveтам, где он не нужен. - В подозрительном классе временно добавьте в copy- и move-конструкторы вывод в лог с разными метками — прогон теста покажет, что именно вызывается.
- Для контейнеров проверьте
std::is_nothrow_move_constructible_vчерезstatic_assert. - На горячем пути сравните число аллокаций до и после через профилировщик кучи (например, heaptrack на Linux), а не по ощущениям.
Что делать дальше
Пройдитесь по сигнатурам функций, которые сохраняют переданный объект: замените приём по const-ссылке с последующей копией на приём по значению + std::move там, где вызывающие часто передают временные объекты. Уберите std::move из всех return локальных переменных. В своих ресурсных классах добавьте noexcept на move-операции и закройте это static_assert на is_nothrow_move_constructible. После — включите -Wpessimizing-move в CI, чтобы регрессии не возвращались. Perfect forwarding и универсальные ссылки (T&& в шаблонах с std::forward) — отдельная тема, здесь мы её не трогали.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: cppreference — std::move, cppreference — Copy elision
Частые вопросы
Правда ли, что std::move всегда ускоряет программу?
А если у меня старый компилятор без C++17?
Почему мой move-конструктор не вызывается, хотя я написал std::move?
Можно ли использовать объект после std::move?
Что выбрать: приём по значению или две перегрузки const& и &&?
Как поймать лишние std::move в большом проекте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.