Привет, Хабр! Move‑семантика в C++ давно стала повседневной частью языка, но в код‑ревью регулярно всплывают тонкости, на которых ошибаются даже опытные разработчики: лишний std::move в return, забытый noexcept на move‑конструкторе, неработающее перемещение из const‑объекта. Все эти ошибки компилируются без предупреждений, иногда без warnings даже с флагами -Wall -Wextra, и проявляются либо медленным кодом, либо тихим undefined behavior.
Сегодня разберём пять задач на самые частые ловушки move‑семантики. Попробуйте сначала ответить сами, потом сверьтесь с разбором.
Задача 1: лишний std::move в return
Дан простой код, возвращающий вектор по значению:
std::vector<int> create_data(size_t n) { std::vector<int> result; result.reserve(n); for (size_t i = 0; i < n; ++i) { result.push_back(static_cast<int>(i * i)); } return std::move(result); }
Вопрос: что не так с
return std::move(result)и как этот код повлияет на производительность по сравнению с обычнымreturn result?
Когда функция возвращает локальную переменную по значению, компилятор имеет право применить named return value optimization (NRVO). Это оптимизация, при которой объект сразу конструируется в памяти вызывающей стороны, без отдельного шага перемещения или копирования. С C++17 для определённых случаев copy elision стал обязательным, для NRVO он остался разрешённым, но не гарантированным.
std::move принимает аргумент по lvalue‑ссылке и возвращает rvalue‑ссылку. Когда return получает не саму локальную переменную, а результат функции std::move, NRVO становится недоступным: компилятор больше не видит возврат именованного локального объекта, он видит возврат rvalue, для которого NRVO не применяется. В лучшем случае произойдёт move‑конструирование, в худшем для типов без move‑конструктора будет копирование.
Для std::vector<int> это пара лишних указателей и атомарная операция в аллокаторе, что почти незаметно. Для типа с дорогим move‑конструктором или для типа, у которого move‑конструктор удалён, а NRVO бы сработал, разница станет ощутимой. Правило простое: возвращайте локальные объекты как есть, без std::move, доверяйте компилятору. Современные компиляторы под -Wpessimizing-move (GCC, Clang) выдают предупреждение на такой код.
Задача 2: self‑move‑assignment
Класс управляет динамическим буфером и реализует move‑assignment:
class Buffer { public: Buffer(size_t size) : size_(size), data_(new char[size]) {} ~Buffer() { delete[] data_; } Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) { other.data_ = nullptr; other.size_ = 0; } Buffer& operator=(Buffer&& other) noexcept { delete[] data_; data_ = other.data_; size_ = other.size_; other.data_ = nullptr; other.size_ = 0; return *this; } private: size_t size_; char* data_; };
Что произойдёт при выполнении buf = std::move(buf)? И главное, как починить это без избыточных проверок при обычном использовании?
При buf = std::move(buf) параметр other ссылается на тот же объект, что и *this. Первая же строка delete[] data_ освобождает буфер. Дальше data_ = other.data_ присваивает указатель, который только что стал dangling: он указывает на освобождённую память. В завершение other.data_ = nullptr обнуляет тот же самый data_, потому что это одно и то же поле. Деструктор позже попытается удалить nullptr (это безопасно), но в промежутке между присваиванием и обнулением объект находится в состоянии с висячим указателем.
Стандарт требует, чтобы после move‑assignment объект‑приёмник находился в валидном, хотя и неопределённом состоянии. Standard library контейнеры обычно проверяют self‑move и делают раннее возвращение, но и это не строгое требование стандарта. Для собственных классов проблема решается через идиому copy‑and‑swap или через явную проверку:
Buffer& operator=(Buffer&& other) noexcept { if (this != &other) { delete[] data_; data_ = std::exchange(other.data_, nullptr); size_ = std::exchange(other.size_, 0); } return *this; }
Проверка this != &other добавляет один branch на каждое присваивание, что в большинстве случаев предсказывается процессором правильно и стоит почти ничего. Альтернатива через swap избавляет от проверки, но меняет момент освобождения памяти, что иногда важно для долгоживущих ресурсов.
Задача 3: emplace_back и move‑only типы
Код выглядит логично:
struct Widget { explicit Widget(int id, std::string name) : id_(id), name_(std::move(name)) {} int id_; std::string name_; }; std::vector<Widget> widgets; widgets.push_back(Widget{1, "alpha"}); widgets.emplace_back(2, "beta"); widgets.emplace_back(Widget{3, "gamma"});
Все три строки компилируются. Какая из них делает лишнюю работу и почему вообще в принципе существует разница между push_back(Widget{...}) и emplace_back(...) с теми же аргументами?
push_back(Widget{1, "alpha"})создаёт временныйWidgetв стеке, затем перемещает его в вектор через move‑конструктор. Два конструктора: обычный плюс move.emplace_back(2, "beta")конструируетWidgetсразу внутри буфера вектора, передавая аргументы в конструктор через perfect forwarding. Один конструктор: обычный.emplace_back(Widget{3, "gamma"})создаёт временныйWidget, потомemplace_backидентифицирует, что переданный аргумент этоWidget&&, и использует move‑конструктор для размещения объекта в буфере. Два конструктора: обычный плюс move. Поведение совпадает сpush_back(Widget{...}), разница только в синтаксисе.
Реальная экономия от emplace_back появляется только когда аргументы передаются для конструирования объекта на месте, а не когда передаётся уже сконструированный объект. Многие разработчики используют emplace_back везде из соображений «он быстрее», получая ровно тот же код, что у push_back, но с менее читаемым синтаксисом и потенциально неявными конверсиями. Используем emplace_back только когда конструируется новый объект из его аргументов, а не когда передаётся уже готовый.
Дополнительный нюанс в том, что emplace_back принимает аргументы через универсальные ссылки и не проверяет тип на этапе вызова. Конструктор может оказаться explicit, и emplace_back его вызовет, тогда как push_back от такого же аргумента откажется. Это иногда удобно, иногда приводит к незаметным неявным преобразованиям.
Задача 4: std::move на const объекте
Код выглядит подозрительно эффективным:
class StringHolder { public: StringHolder(std::string s) : data_(std::move(s)) {} void process(const std::string& input) { data_ = std::move(input); } private: std::string data_; };
Внутри process стоит std::move(input), входной аргумент типа const std::string&. Компиляция проходит без ошибок и без предупреждений. Что в реальности произойдёт при выполнении этой строки?
Произойдёт копирование вместо ожидаемого перемещения, и компилятор не подскажет.
Разберём по типам:
input имеет тип const std::string&. std::move(input) возвращает static_cast<const std::string&&>(input), то есть rvalue‑ссылку на константный объект. Тип результата: const std::string&&.
У std::string есть два оператора присваивания, которые могли бы подойти: operator=(const string&) (copy assignment) и operator=(string&&) noexcept (move assignment).
Перегрузка operator=(string&&) принимает rvalue‑ссылку на неконстантный объект, и она не примет const std::string&&, потому что это потребовало бы отбрасывания const. Перегрузка operator=(const string&) принимает lvalue‑ссылку на const, и const std::string&& к ней приводится через стандартные правила (rvalue‑ссылка может биндиться к const lvalue reference).
В итоге выбирается copy assignment, и std::move тут работает как очень дорогой no‑op: он только меняет тип выражения, но реального move не происходит, потому что move‑конструктор и move‑assignment перемещаемого объекта требуют, чтобы источник был неконстантным.
Исправление: убрать
std::move, потому что от него тут нет пользы, или изменить сигнатуруprocess, чтобы приниматьstd::stringпо значению илиstd::string&&. Если намерение было передать владение, правильная сигнатура такая:
void process(std::string input) { data_ = std::move(input); }
Вызывающая сторона сама решает, копировать или перемещать в аргумент, а внутри функции input это неконстантное lvalue, для которого move работает корректно.
Задача 5: noexcept на move‑конструкторе и vector::push_back
Два класса с одинаковой логикой, но разными аннотациями:
struct Slow { std::string data; Slow() = default; Slow(const Slow&) = default; Slow(Slow&& other) : data(std::move(other.data)) {} }; struct Fast { std::string data; Fast() = default; Fast(const Fast&) = default; Fast(Fast&& other) noexcept : data(std::move(other.data)) {} }; std::vector<Slow> slow_vec; std::vector<Fast> fast_vec; for (int i = 0; i < 1'000'000; ++i) { slow_vec.push_back({}); fast_vec.push_back({}); }
Бенчмарки показывают, что fast_vec заполняется заметно быстрее. Почему noexcept на move‑конструкторе влияет на производительность vector::push_back, если внутри логика идентична?
std::vector даёт strong exception guarantee: если операция выбрасывает исключение, состояние контейнера остаётся таким же, как до операции. Когда push_back исчерпывает текущую capacity, вектор аллоцирует новый буфер, копирует или перемещает элементы и освобождает старый. Если в процессе move случится исключение, восстановить исходное состояние невозможно: часть элементов уже перемещена, их данные ушли в новый буфер.
Чтобы сохранить strong exception guarantee, vector использует трюк: при reallocation он перемещает элементы только если move‑конструктор помечен noexcept. Иначе элементы копируются, что медленнее, но безопасно: при ошибке копирования старый буфер цел, можно откатиться.
Утилита std::move_if_noexcept именно это и проверяет. Для типа Slow move‑конструктор без noexcept, поэтому vector копирует. Для Fast move‑конструктор гарантированно не бросает, и vector перемещает.
Практический вывод в том, что для типов с move‑конструктором, который реально не может бросить (например, перемещение std::string, std::unique_ptr, любых типов из стандартной библиотеки с move‑операциями), помечайте move‑конструктор и move‑assignment как noexcept. Это даёт компилятору и стандартной библиотеке возможность использовать оптимальный путь. С C++11 идиоматичный move‑конструктор для класса с pointer‑based владением выглядит так:
class Resource { public: Resource(Resource&& other) noexcept : ptr_(std::exchange(other.ptr_, nullptr)) {} Resource& operator=(Resource&& other) noexcept { if (this != &other) { delete ptr_; ptr_ = std::exchange(other.ptr_, nullptr); } return *this; } private: Data* ptr_; };
Если в move‑операциях есть код, который теоретически может бросить (например, выделение памяти под буфер для compact‑перемещения), noexcept ставить нельзя, иначе исключение приведёт к std::terminate. В таких случаях приходится мириться с тем, что vector будет копировать при reallocation, или использовать deque/list, у которых другие гарантии и нет переаллокаций.
В итоге
Move‑семантика выглядит простой механикой, пока не сталкиваешься с тем, как она взаимодействует с copy elision, const‑квалификаторами, exception guarantees стандартных контейнеров и правилами перегрузки. Лишний std::move в return перекрывает NRVO, self‑move‑assignment без проверки делает объект невалидным, emplace_back с готовым объектом не даёт обещанной экономии, move на const‑объекте тихо превращается в копирование, отсутствие noexcept заставляет vector копировать вместо перемещения.
Все пять ситуаций компилируются без ошибок. Часть из них ловится предупреждениями компилятора (-Wpessimizing-move, -Wself-move в новых версиях GCC и Clang), часть только бенчмарками или статическими анализаторами. Самый практичный способ не наступать на все это: помечать move‑операции noexcept там, где это правда, не дублировать std::move в return‑выражениях, проверять типы параметров перед применением std::move и помнить, что emplace_back не плацебо от всех проблем.
Ошибки с move‑семантикой часто упираются не только в синтаксис, но и в более базовые вещи: владение ресурсами, время жизни объектов и работу с памятью. Чтобы лучше понимать, почему C++ ведёт себя именно так, стоит разобраться с управлением памятью на уровне объектов и типичных ошибок.
2 июля, 20:00. «Всё, что нужно знать об управлении памятью в C++». Записаться
В OTUS обучение строится на живых занятиях: преподаватели‑практики разбирают реальные рабочие ситуации, отвечают на вопросы и помогают понять, как применять материал в разработке, а не просто запомнить теорию.
Ещё больше тем и дат — в полном дайджесте открытых уроков.
