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

Ещё больше тем и дат — в полном дайджесте открытых уроков.