Проблема: когда из-за «оптимизации» код замедляется
Начнём с ситуации, в которой могут спотыкаться даже опытные разработчики. Допустим, вы написали на C++ следующий код, который выглядит совершенно нормальным:
struct HeavyObject {
std::string data;
HeavyObject(HeavyObject&& other) : data(std::move(other.data)) {}
HeavyObject(const HeavyObject& other) : data(other.data) {}
HeavyObject(const char* s) : data(s) {}
};
std::vector<HeavyObject> createData() {
std::vector<HeavyObject> data;
// ... заполняем данными ...
return data;
}
void processData() {
auto result = createData();
}
Этот код работоспособен. Он компилируется и выполняется. Но, в зависимости от того, как именно вы реализовали ваши типы, код, возможно, станет выполнять тысячи дорогостоящих операций копирования вместо дешёвых перемещений — а вы этого даже не заметите.
Вот что здесь происходит за кулисами: когда вашему std::vector требуется вырасти сверх зарезервированной под него ёмкости, он выделит новую память и переместит (move) все элементы из старой области памяти в новую. Но в этом-то и загвоздка. Если ваш перемещающий конструктор не помечен ключевым словом noexcept, то компилятор вообще не будет его использовать, а перейдёт к резервной стратегии и станет копировать каждый отдельный элемент.
Почему так? Потому что std::vector требуется предоставлять так называемую «строгую гарантию безопасности исключений». Эта витиеватая формулировка означает, что, если при перевыделении памяти что-то пойдёт не так, то исходный вектор должен остаться совершенно нетронутым. Если в процессе перевыделения копия выбросит исключение — никаких проблем, исходный вектор это не затронет. Однако, если исключение будет выброшено при перемещении, то некоторые элементы к данному моменту уже могут быть перемещены, и в таком случае исходный вектор окажется повреждён.
Поэтому здесь стандартная библиотека «перестраховывается». Если перемещающий конструктор может выбросить исключение (поскольку вы не пометили его как noexcept), то контейнеры попросту всё копируют. Вы такой «оптимизации» хотели добиться? Ничего не оптимизировалось.
А вот здесь уже интересно: оказывается, std::move не устраняет эту проблему «по волшебству». На самом деле, если использовать эту функцию неправильно, то всё можно только усугубить. Позвольте, покажу вам, почему.
Механика: что на самом деле представляет собой std::move?
Открою вам истину, которая, возможно, вас удивит: std::move на самом деле ничего никуда не двигает. Когда вы вызываете std::move, ни один байт в памяти не меняет местоположения. Во всей стандартной библиотеке C++ эта функция, пожалуй, названа наиболее превратно.
Так что же она делает в действительности? Давайте рассмотрим, как именно она реализована в стандартной библиотеке (ниже будем разбирать реализацию, взятую из libstdc++, но реализации из других стандартных библиотек также похожи на эту):
template<typename _Tp>
constexpr typename std::remove_reference<_Tp>::type&&
move(_Tp&& __t) noexcept
{
return static_cast<typename std::remove_reference<_Tp>::type&&>(__t);
}
Если вы на это смотрите и думаете: «так это же просто приведение!» — то вы абсолютно правы. Да, всего лишь приведение. Функция std::move принимает всё, что вы ей передаёте, отсекает любые квалификаторы ссылок (часть std::remove_reference), добавляет &&, чтобы превратить её в rvalue-ccылку (правое значение), после чего применяет к этому типу приведение static_cast. Вот и вся функция.
Давайте объясню ещё проще: выполняя std::move, мы как будто ставим на объект отметку «с этим я закончил, можете брать то, что в нём содержится». Сам акт «взятия» материала происходит позже, когда какой-то другой код «видит» этот знак. Таким образом, этот «знак» (ссылочный тип rvalue) сообщает компилятору, что нужно выбрать перемещающий конструктор (Move Constructor), а не копирующий конструктор (Copy Constructor).
Понятие о категориях значений: основа, на которой строится всё
Теперь, чтобы понять, почему std::move работает именно так, давайте подробнее разберём концепцию C++, которая называется «категории значений» (Value Categories). Изучив её, вы поймёте не только std::move, но и семантику перемещения в целом.
В современном C++ каждое выражение относится к той или иной категории значений, и от этой категории зависит, как именно может использоваться это выражение. Можно считать, что каждая из этих категорий позволяет ответить на два вопроса о конкретном выражении:
1. Возможно ли его идентифицировать? (могу ли я получить его адрес?)
2. Могу ли я что-то из него переместить? (можно ли заполучить его ресурсы?)
На базе этих вопросов C++ подразделяет выражения на несколько категорий. Мы сосредоточимся на трёх главных:
graph TD;
Expression-->GL[glvalue<br/>'has identity'];
Expression-->R[rvalue<br/>'moveable'];
GL-->L[lvalue<br/>'I have a name<br/>and an address'];
GL-->X[xvalue<br/>'I'm about to die<br/>take my stuff!'];
R-->X;
R-->PR[prvalue<br/>'I'm a temporary<br/>with no name'];
style X stroke:#4ade80,stroke-width:2px

Давайте разберём их подробнее:
lvalue (левое значение): это наиболее привычная категория. lvalue — это любая именованная сущность, занимающая конкретное место в памяти. Взять её адрес можно при помощи оператора
&. В следующей записи:
int x = 5;
std::string name = "Alice";
и x, и name являются левыми значениями. У них есть имена, адреса, и они могут постоянно находиться за пределами текущего выражения. Можно считать их «сущностями, обладающими постоянными адресами».
prvalue (чистое правое значение): к этой категории относятся временные значения, не обладающие долговременной идентичностью. Обычно они создаются в ходе выполнения выражения и предназначены для того, чтобы их сразу же использовали. Например:
42 // литерал, prvalue
5 + 3 // результат сложения, prvalue
std::string("hello") // временный объект, prvalue
У этих значений нет ни имён, ни адресов, которые можно было бы взять. Они существуют лишь в течение жизни того выражения, в рамках которого были созданы. Такие «преходящие» сущности.
xvalue (значение со сроком годности): это особый случай, и именно такие значения создаёт std::move. Такое значение обладает идентичностью (на него можно сослаться, так как оно обладает именем), но обращаются с ним так, как будто оно вот-вот устареет. В него вкладывается такой смысл: «строго говоря, оно пока существует, но я с ним уже закончил, так что считайте его временным».
Когда вы пишете std::move(name), вы не перемещаете name, а преобразуете левое значение name в xvalue. То есть, меняете представление name с точки зрения компилятора. Сама переменная name никуда не девается. Её содержимое не перемещается. Нет, просто теперь компилятор трактует данное выражение как «срок годности этого объекта истекает, его ресурсы можно присваивать».
Вот почему я сказал, что std::move — это просто приведение. Функция меняет категорию значения у некоторого выражения, но не сам объект. При этом она приводит одну категорию значений к другой (xvalue). Фактическое перемещение ресурсов происходит позднее, когда перемещающий конструктор или оператор перемещающего присваивания вызывается для операции над этим xvalue.
Можно сформулировать так: std::move ставит на ваш объект знак «МОЖНО БРАТЬ». Сам акт «взятия» происходит, когда кто-то, умеющий перемещать, видит этот знак и воздействует на объект.
Упрощённый подход: три ошибки, из-за которых страдает производительность
Теперь, когда мы разобрались, как на самом деле работает std::move, поговорим о том, как этой функцией зачастую злоупотребляют, из-за чего производительность только падает, а ничуть не улучшается.
Ошибка 1: return std::move(local_var): слом самой лучшей оптимизации компилятора
Пожалуй, это наиболее распространённый вариант злоупотребления std::move.
std::string createString() {
std::string result = "expensive data";
// ... здесь проделывается масса всего и нарабатывается результат ...
return std::move(result); // НЕ ДЕЛАЙТЕ ТАК!
}
Можно подумать: «Эй, я ведь возвращаю локальную переменную — поэтому должен применить std::move, во избежание копирования!» Но на самом деле вы таким образом только делаете хуже. Объясню, почему.
В современных компиляторах C++ задействуется оптимизация под названием NRVO (оптимизация именованного возвращаемого значения). Вот как она работает: когда вы возвращаете локальную переменную, компилятору разрешено сконструировать эту переменную именно в той точке памяти, где вызывающая сторона ожидает найти возвращаемое значение. Таким образом, ни копирование, ни перемещение вообще не требуется. Объект конструируется прямо на месте. Иными словами, вместо того, чтобы:
1. Создать result в кадре стека функции.
2. Копировать или переместить result в контекст вызывающей стороны.
3. Разрушить result в кадре стека функции.
Компилятор может оптимизировать три этих шага до одного:
1. Сконструировать result непосредственно в контексте вызывающей стороны.
Так исключается одновременно две операции: как перемещение, так и разрушение локального объекта. Это лучше перемещения, так как получается ноль операций вместо одной.
Но вот в чём загвоздка: у NRVO есть правила. Чтобы оптимизация работала, оператор возврата обязательно должен возвращать переменную по имени. Когда вы пишете return std::move(result);, вы больше не возвращаете result по имени. Вместо этого вы возвращаете xvalue, созданное std::move.
ИТАК, компилятор говорит: «Знаете, я не могу оптимизировать именованное возвращаемое значение, так как в данном случае возвращается не только имя». Поэтому вам ничего не остаётся, кроме как прибегнуть к операции перемещения. Вы обменяли операцию нулевой стоимости (NRVO) на одиночную операцию (перемещение). Это называется «пессимизация» (акт, противоположный оптимизации).
«Но подождите», — могли бы сказать вы, — «ведь перемещение — это не затратная операция»? Да, перемещать обычно дешевле, чем копировать. Но отсутствие операции — это ноль затрат, а любая одиночная операция связана с затратами. А при работе с типами, перемещение которых является нетривиальным (может быть, данная строка предполагает небольшие строковые оптимизации или другие сложности) вы, возможно, навязываете дополнительную работу, которой компилятор мог бы вообще избежать.
Исправить это просто: возвращайте переменную по имени:
std::string createString() {
std::string result = "expensive data";
// ... здесь выполняется много работы ...
return result; // Правильно - активируйте NRVO или перемещения, если осуществить NRVO невозможно
}
Компилятор в самом деле будет оптимизировать именованное возвращаемое значение, если сможет. Если не сможет (например, из-за того, что в программе сложный поток управления), то он автоматически станет трактовать возвращаемое значение как правое и перемещать его за вас. Вам самим ничего не потребуется делать.
Правило: никогда не используйте std::move в инструкции возврата с локальной переменной. Компилятор умнее, чем вы думаете.
Ошибка 2: const T obj; std::move(obj): незаметная копия
Вот это скрыто и поэтому вредит:
void process() {
const std::vector<int> data = getData();
consume(std::move(data)); // Сюрприз: это КОПИРОВАНИЕ, а не перемещение!
}
Сейчас пошагово вам объясню, почему это не работает. Если пометить что-либо как const, то тем самым вы сообщаете компилятору: «состояние этого объекта не изменится». Но перемещение — это, в сущности, изменение состояния, так как при перемещении требуется взять ресурсы от одного объекта и перенести к другому. Состояние исходного объекта совершенно обязательно изменится (обычно в результате он становится пуст или равен null).
Что же произойдёт при попытке переместить константный объект? Рассмотрим, что в данном случае увидит компилятор:
1. std::move(data) возвращает const std::vector<int>&& (константную rvalue-ссылку)
2. Сигнатура перемещающего конструктора такова: vector(vector&& other) (принимает неконстантную rvalue-ссылку)
3. const T&& не может привязаться к параметру T&& (вы не сможете удалить константу при преобразовании)
4. Но const T&& сможет привязаться к параметру const T& (копирующий конструктор)
5. Поэтому компилятор предпочтёт использовать копирующий конструктор.
Написанный вами код выглядит так, будто в нём происходит перемещение, но компилятор незаметно прибегает к резервному варианту (копированию), так как по правилам он не может выполнить перемещение от константного объекта. Продолжают происходить все эти дорогостоящие операции копирования, которых вы так пытались избежать.
Вот что здесь происходит на самом деле:
// Вы написали:
consume(std::move(data));
// Фактически компилятор видит:
// Невозможно вызвать consume(vector&&), поскольку данные являются константой
// Прибегаем к consume(const vector&) в качестве резервного варианта
// В результате вызывается КОПИРУЮЩИЙ конструктор
Это один из самых опасных багов, поскольку о нём не выдаётся предупреждений, и он не провоцирует ошибку. Ваш код компилируется и работает, но на самом деле работает не так, как вы полагаете.
Правило: никогда не применяйте std::move с константными объектами. Если что-то является const, то из него по определению ничего нельзя перемещать. Для перемещения требуется изменить исходный объект, а const означает «нельзя изменить».
Ошибка 3: Использование объекта после перемещения — это игра с огнём
Вот третья распространённая ошибка:
std::string name = "Alice";
std::string movedName = std::move(name);
std::cout << name << std::endl; // Что тут происходит?
В стандарте C++ сказано, что после перемещения из объекта стандартной библиотеки этот объект остаётся в «действительном, но неуказанном состоянии». Давайте разберём, что означает эта загадочная фраза.
«Действительный» объект по-прежнему сохраняет инварианты своего класса. В случае с std::string это означает:
Среди его внутренних указателей нет висячих
Его размер соотносится с его ёмкостью
Вызывать его деструктор безопасно
Можно вызывать методы, не содержащие предусловий
«Неуказанное» состояние — такое, в котором значение вам не известно. Может быть, name сейчас пустует. Может быть, в нём по-прежнему содержится «Alice». Может быть, содержится что-то совершенно иное. В стандарте это прямо не сказано, а реализации варьируются.
Вот что можно без опаски делать с объектом после перемещения:
Разрушать его (он правильно очистится)
Присваивать ему значение (
name = "Bob"— это нормально)Вызывать методы без предусловий (
name.empty(), name.clear())
Вот чего делать не следует:
Читать его значение (
std::cout << name)Вызывать методы с предусловиями (
name[0]илиname.back()предполагают, что строка непуста)Делать какие-либо допущения о его состоянии
На практике в большинстве реализаций стандартной библиотеки информация, перемещённая от объектов, остаётся в предсказуемом состоянии (строки обычно пусты, векторы тоже), но это не гарантировано. Код, который на это опирается, не поддаётся портированию и, строго говоря, приводит к неопределённому поведению.
Вот как я себе это представляю: обращаться с перемещённым объектом следует так, как если бы вы только что его разрушили. Технически он до сих пор жив (ему можно что-то присвоить), но его значение пропало. Всё равно как человек, которому стёрли память: физически тело ещё функционирует, но всё, что составляло его личность, уже утрачено.
Правило: после того, как вызовете std::move применительно к объекту, не используете этот объект больше ни для чего, кроме как чтобы присвоить ему новое значение или разрушить его. Считайте его фактически мёртвым.
Правильно реализуем семантику перемещения
Теперь, разобравшись с тем, что делать не надо, поговорим о том, как всё делать правильно. Если вы реализуете класс, управляющий ресурсами (памятью, дескрипторами файлов, сетевыми соединениями, т.д.), то вам придётся запрограммировать семантику перемещения. А уже есть устоявшийся паттерн, описывающий, как это делать правильно.
Правило пяти
Рассмотрим пять функций-членов. Чтобы реализовать любую из них, обычно требуется реализовать и все остальные четыре:
1. Деструктор
2. Копирующий конструктор
3. Оператор копирующего присваивания
4. Перемещающий конструктор
5. Оператор перемещающего присваивания
Это и есть «правило пяти» (ранее шла речь о «правиле трёх», до того, как была изобретена семантика перемещения). Сейчас покажу вам полную правильную реализацию, а потом разберу её фрагмент за фрагментом:
class Resource {
private:
int* data;
size_t size;
public:
// Конструктор
Resource(size_t n) : data(new int[n]), size(n) {
std::cout << "Constructing Resource with " << n << " elements\n";
}
// Деструктор
~Resource() {
std::cout << "Destroying Resource\n";
delete[] data;
}
// Копируюший конструктор: создаёт глубокую копию
Resource(const Resource& other)
: data(new int[other.size]), size(other.size) {
std::cout << "Copy constructing Resource\n";
std::copy(other.data, other.data + size, data);
}
// Оператор копирующего присваивания: создаёт глубокую копию
Resource& operator=(const Resource& other) {
std::cout << "Copy assigning Resource\n";
if (this != &other) { // защита от самоприсваивания
// Сначала выделяем память во ВРЕМЕННЫЙ указатель.
int* new_data = new int[other.size];
std::copy(other.data, other.data + other.size, new_data);
delete[] data;
// Обновляем состояние
data = new_data;
size = other.size;
}
return *this;
}
// Перемещающий конструктор: передаёт владение
Resource(Resource&& other) noexcept
: data(std::exchange(other.data, nullptr)),
size(std::exchange(other.size, 0)) {
std::cout << "Move constructing Resource\n";
}
// Оператор перемещающего присваивания: передаёт владение
Resource& operator=(Resource&& other) noexcept {
std::cout << "Move assigning Resource\n";
if (this != &other) { // Защита от самоприсваивания
delete[] data;
data = std::exchange(other.data, nullptr);
size = std::exchange(other.size, 0);
}
return *this;
}
};
Давайте разберём каждый из них, поскольку каждый из них служит конкретной цели:
С конструктором и деструктором всё просто: конструктор выделяет ресурсы, а деструктор их высвобождает. Это простейшее RAII (приобретение ресурса есть его инициализация), где длительность жизни ресурса увязывается с длительностью жизни объекта.
Копирующий конструктор и оператор копирующего присваивания работают именно так, как вы ожидаете: они создают копию ресурса, совершенно не зависящую от оригинала. Если некоторый ресурс владеет фрагментом памяти, то при копировании его создаётся новый ресурс, владеющий совершенно иным фрагментом памяти, в котором содержатся такие же данные. После копирования ни один из этих объектов не влияет на другой.
Перемещающий конструктор и перемещающее присваивание — вот это уже интересно. Эти функции не создают новый ресурс, а утаскивают ресурс из исходного объекта. Давайте подробнее рассмотрим перемещающий конструктор:
Resource(Resource&& other) noexcept
: data(std::exchange(other.data, nullptr)),
size(std::exchange(other.size, 0)) {
std::cout << "Move constructing Resource\n";
}
Понятие о std::exchange: чистый путь к цели
Обратите внимание: здесь мы используем std::exchange. Это вспомогательная функция из <utility>, выполняющая две вещи за одну операцию:
1. Возвращает актуальное значение первого аргумента.
2. Задаёт первый аргумент в качестве второго.
Таким образом, std::exchange(other.data, nullptr) означает:
Получить актуальное значение
other.data(указатель на ресурс).Установить
other.dataвnullptr(это указывает, что other больше не владеет ресурсом).Возвращает значение исходного указателя.
Это отлично подходит для реализации перемещения, поскольку здесь мы делаем именно то, что требуется для перемещения:
1. Принимаем владение ресурсом от other.
2. Оставляем other в допустимом пустом виде (так его деструктор не высвободит ресурс, который мы только что взяли).
Можно было бы написать это, не прибегая к std::exchange:
Resource(Resource&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
Но std::exchange чище и более явно сообщает, что здесь происходит. Это идиоматический способ реализации перемещения, существующий в современном C++.
Наглядно покажу, что здесь происходит:
graph LR;
subgraph Before Move
A[Object A<br/>data: 0x1234<br/>size: 1000] -->|owns| Heap[Heap Memory<br/>0x1234<br/>1000 ints];
B[Object B<br/>data: null<br/>size: 0];
end
subgraph After Move
A2[Object A<br/>data: null<br/>size: 0];
B2[Object B<br/>data: 0x1234<br/>size: 1000] -->|owns| Heap2[Heap Memory<br/>0x1234<br/>1000 ints];
end
style Heap fill:#4ade80,stroke:#fff,color:#000
style Heap2 fill:#4ade80,stroke:#fff,color:#000

Сама память никуда не двигается. Указатели меняются местами. Объект B принимает владение памятью кучи, а объект A остаётся в безопасном пустом состоянии.
Критическая важность noexcept
Теперь давайте обсудим, почему обе операции перемещения помечаются как noexcept. Это ключевое слово — не опциональное, а принципиально важное для производительности.
Помните, я выше упоминал, что std::vector не будет использовать перемещающий конструктор при перевыделении памяти, если только он не помечен как noexcept? Вот почему:
Когда std::vector растёт, он должен предоставлять строгую гарантию безопасности исключений. Это означает, что, если при перевыделении памяти что-то пойдёт не так, исходный вектор должен остаться совершенно неизменным. Давайте продумаем несколько сценариев:
Сценарий 1: использование копирующих конструкторов
Создаём новый блок памяти
Копируем элемент 1 в новую область памяти (может выбросить исключение)
Если будет выброшено исключение — удаляем новую память, исходный вектор остаётся незатронутым
Копируем элемент 2 в новую область памяти (может выбросить исключение)
Если будет выброшено исключение, то разрушаем элемент 1 в новой памяти, удаляем новую память, оригинал остаётся незатронутым
Продолжаем…
Независимо от того, когда именно выбрасывается исключение, можно очистить последствия, а с исходным вектором всё будет нормально.
Сценарий 2: использование перемещающих конструкторов, которые могут выбрасывать исключения
Создаём новый блок памяти
Перемещаем элемент 1 в новую область памяти (может выбросить исключение)
Если будет выброшено исключение, то элемент 1 перейдёт в неуказанное состояние
Перемещаем элемент 2 в новую область памяти (может выбросить исключение)
Если будет выброшено исключение, учтём, что элемент 1 уже был перемещён (и, возможно, был при этом повреждён). В последнем случае элемент 2 теперь тоже повреждён
Восстановить исходное состояние мы не сможем!
Если при перемещениях могут выбрасываться исключения, то невозможно гарантировать неповреждённость исходного вектора после исключения, возникшего на этапе перевыделения памяти. Поэтому std::vector оказывается перед выбором: если ваш перемещающий конструктор может выбрасывать исключения (не помечен как noexcept), то функция станет работать через копирование — чтобы соблюсти гарантии.
Как это сказывается на практике: если вы забудете пометить ваш перемещающий конструктор ключевым словом noexcept, то всякий раз при росте вектора он будет копировать все элементы, а не перемещать их. Применительно к вектору сложных объектов это может вылиться в миллионы ненужных операций выделения памяти.
Правило: всегда, всегда, всегда помечайте перемещающие конструкторы и операторы перемещающего присваивания ключевым словом noexcept, если только у вас нет какой-то исключительно веской причины так не делать (и вы так почти никогда не поступаете).
Сравнение std::move и std::forward: два инструмента, решающих разные задачи
Теперь, разобравшись с std::move, давайте кратко изучим, как отличать её от близкородственной конструкции std::forward. Обе они — это функции приведения, имеющие дело с категориями значений, но они служат для достижения разных целей.
std::move — это безусловная конструкция. Она всегда преобразует свой аргумент в rvalue-ссылку, независимо от того, что вы будете в ней передавать:
template<typename T>
void process(T&& arg) {
// здесь аргумент — это lvalue (у него есть имя!)
consume(std::move(arg)); // Всегда передаёт rvalue для потребления
}
Пусть в типе arg и указано &&, у него есть имя — а значит, это lvalue. Таково правило: есть имя — значит, перед нами lvalue. Итак, внутри process, arg находится lvalue, и мы при помощи std::move преобразуем его обратно в rvalue для последующего потребления.
std::forward — условная. Она сохраняет у своего аргумента его категорию значения. В шаблонах это используется для прямой передачи:
template<typename T>
void wrapper(T&& arg) {
// Если аргумент исходно представлял собой lvalue, передаём его как lvalue
// Если аргумент исходно представлял собой rvalue, передаём его как rvalue
process(std::forward<T>(arg));
}
Ключевое отличие в том, что std::forward каков был arg на этапе передачи его к wrapper и хранит эту информацию. Если вызвать wrapper(x) с lvalue x, то std::forward<T>(arg) произведёт lvalue. Если вызвать wrapper(std::move(x)) с rvalue, то std::forward<T>(arg) произведёт rvalue.
Вот в каких случаях нужно использовать каждый из этих вариантов:
Используем std::move, когда известно, что нужно переместить откуда-то какое-то значение, и работа с этим значением закончена
Используем std::forward только в шаблонных функциях с применением передаваемой ссылки (
T&&, гдеT— это шаблонный параметр), когда хотим передавать аргументы, в то же время не изменяя принадлежность каждого из них к lvalues или rvalues
В 99% нормального кода обычно используется std::move. std::forward — в основном для авторов библиотечного кода и разработчиков фреймворков, которые стараются идеально обёртывать другие функции.
Контекст современного C++: как развивалась семантика перемещения
Семантика перемещения впервые появилась в C++11, но продолжила развиваться и в последующих стандартах. Рассмотрим некоторые ключевые разработки, влияющие на то, как теперь пишут код на современном C++.
C++14: constexpr Move
В C++11 std::move — это операция, выполняемая только во время выполнения. В C++14 это изменили, пометив std::move как constexpr.
Данная деталь может показаться малозначительной; в конце концов, std::move — это просто приведение. Но это был первый шаг к внедрению сложной логики, насыщенной перемещениями (например, сортировок и перестановок), которая целиком происходит во время компиляции.
Обязательный пропуск копий в C++17
До C++17 пропуск копий (в том числе, оптимизации RVO и NRVO) считался разрешённой оптимизацией, но не требуемой. Компилятор мог это сделать, но не был обязан. Данная ситуация изменилась в C++17.
Если вы возвращаете prvalue (сугубо временное), то компилятору теперь требуется сконструировать объект непосредственно в слоте вызывающей стороны:
std::string create() {
return std::string("hello"); // В C++17 и последующих стандартах гарантированно нет никакого копирования/перемещения
}
Объект конструируется непосредственно в памяти вызывающей стороны. Всегда. Это не такая оптимизация, которая может произойти; она требуется в соответствии со стандартом.
Этим она отличается от NRVO (оптимизация именованного возвращаемого значения), которая, всё равно остаётся опциональной. Когда мы возвращаем именованную локальную переменную:
std::string create() {
std::string result = "hello";
return result; // NRVO остаётся опциональной, но компиляторы обычно её делают
}
Компилятору разрешается сконструировать result непосредственно в памяти вызывающей стороны, но это не обязательно. На практике современные компиляторы надёжно справляются с этой оптимизацией, но технически она в стандарте не гарантирована.
Вывод: возврат временных значений будет гарантированно эффективен только в C++17 и выше. Не пытайтесь «оптимизировать» такой код с применением std::move.
C++20: Перемещение памяти кучи во время компиляции
Вот тут начинается полное безумие. Пусть std::move и является constexpr со времён C++14, с ней практически ничего нельзя было сделать при работе со стандартными контейнерами, поскольку отсутствовала возможность выделять память во время компиляции.
В C++20 появилась возможность динамически выделять constexpr. Таким образом, теперь std::vector и std::string можно использовать (и перемещать!) внутри функций, являющихся константными выражениями.
constexpr int sum_data() {
std::vector<int> data = {1, 2, 3};
std::vector<int> moved_data = std::move(data); // Действует в C++20!
int sum = 0;
for(int i : moved_data) sum += i;
return sum;
}
// Вектор создаётся, перемещается и разрушается целиком во время компиляции
constexpr int result = sum_data();
Благодаря этому открываются возможности более виртуозного программирования во время компиляции. Теперь можно писать функции constexpr, перемещающие объекты, так, что все вычисления происходят во время компиляции.
C++23: обёртка «функция только для перемещения»
В C++23 появились std::move_only_function, которые подобны std::function, но могут содержать типы, не поддающиеся копированию:
// До C++23 это бы не сработало, так как std::function требует возможность копирования:
// std::function<void()> func = [ptr = std::make_unique<int>(42)]() {
// std::cout << *ptr;
// }; // Ошибка: unique_ptr не поддаё тся копированию!
// Решение из C++23:
std::move_only_function<void()> func = [ptr = std::make_unique<int>(42)]() {
std::cout << *ptr;
}; // Работает! func можно только перемещать, а копировать нельзя
Это особенно полезно при работе с обратными вызовами и обработчиками, которым требуется владеть уникальными ресурсами.
Планы на будущее: тривиальная релокация (пока в разработке)
В процессе разработки новых стандартов комитет не оставляет попыток внедрить в языке «тривиальную релокацию» — возможность, о которой стоит знать уже сейчас, даже пока она не вошла в стандарт. Основная её идея заключается в следующем: применительно ко многим типам перемещение объекта сводится к тому, чтобы просто скопировать его байты и забыть об оригинале.
Задумайтесь, что в настоящий момент происходит, когда требуется нарастить std::vector<std::string> (перевыделить на него память).
1. Для каждой строки вызывается перемещающий конструктор (указатель копируется, старый указатель сводится к null)
2. Для каждой строки в старой памяти вызывается деструктор (он проверяет, является ли указатель null, и если так — ничего не делает)
Так много функций приходится вызывать. Но концептуально можно было бы просто:
1. memcpy целый блок строк в новую память
2. О старой памяти просто забываем (никаких деструкторов не требуется, ведь все старые строки всё равно равны null)
Это и называется «тривиальная релокация»: чтобы перенести тип, достаточно скопировать соответствующие байты.
В настоящее время эта возможность разрабатывается в рамках двух конкурирующих предложений:
P1144 (от Артура О’Дуайера): соответствует тому, как эта стратегия уже реализована в библиотеках, таких как Folly, BDE и Qt.
P2786 (от Джузеппе Д’Анджело и других): было включено в рабочую повестку конференции, состоявшейся в Хагенберге в 2025 году, но до сих пор воспринимается противоречиво
Эти противоречия обусловлены отличиями в семантике и интерфейсе. P2786 было добавлено в повестку, несмотря на скептические замечания разработчиков о том, что его семантика не согласуется с существующими практиками. Многие специалисты, занятые поддержкой крупных библиотек, предпочитают дизайн, воплощённый в P1144.
Какое значение это имеет для вас? Если/когда тривиальная релокация войдёт в стандарт, такие операции как перевыделение векторов, возможно, станут гораздо быстрее (чем сейчас) для подходящих типов, а при работе с большими контейнерами могут стать на порядки быстрее. Но ещё предстоит решить в деталях, как именно перейти на такой режим, и какие гарантии при этом будут предоставляться.
Бенчмарки: если реализовать всё вышеописанное правильно - как это отразится на производительности
Рассмотрим некоторые конкретные числа, чтобы понять, как всё это повлияет на производительность. Я прогонял все бенчмарки на машине с архитектурой x86_64, использовал при этом компилятор GCC 13.3.0, уровень оптимизации -O3, операции замерял на векторе, в котором содержится 10 000 специализированных объектов:
Тест 1: стоимость «безопасных» операций
Сравниванием перемещение и копирование вектора, содержащего 10 000 тяжёлых объектов.
Операция |
Время |
Ускорение, раз |
Примечания |
Глубокое копирование |
7,82 мс |
1 |
Исходное значение. Выделяет и копирует память. |
Перемещение (верное) |
1,08 мс |
~7 |
Мгновенная замена указателя |
Перемещение от const |
7,50 мс |
1 |
Ловушка. Незаметно возвращается к глубокому копированию |
Тест 2: Миф о возвращаемом значении
Распространённая «оптимизация», связанная с обёртыванием возвращаемых значений в std::move. Помогает ли она?
Операция |
Время (наилучший прогон) |
Результат |
return x; (NRVO) |
0,83 мс |
Быстрее всего (конструируется с нулевым копированием) |
return std::move(x); |
0,82 мс |
Эквивалентно (в пределах погрешности) |
Они идентичны
Тест 3: Стоимость перевыделения
Реализация типа |
Время |
Штраф |
|
1,63 мс |
Исходный уровень |
|
16,42 мс |
В 10 раз медленнее |
В результате получаем десятикратное замедление.
Ссылка на код бенчмарков в Compiler Explorer: https://godbolt.org/z/95d5PerdE
Давайте рассмотрим эти числа в более широком контексте:
Перемещение в сравнении с копированием: правмльно реализованное перемещение происходит примерно всемеро быстрее, чем копирование той же рабочей нагрузки. Это не опечатка. В семь раз. При копировании требуется выделить память под 10 000 объектов и скопировать их. При перемещении нужно только переставить несколько указателей.
NRVO в сравнении с перемещением: современные компиляторы (такие как GCC 15) достаточно умны и умеют сконструировать возвращаемое значение непосредственно в кадре стека вызывающей стороны (оптимизация именованного возвращаемого значения). Добавив здесь std::move, мы ничего не ускорим, в лучшем случае, эта операция пройдёт без последствий. В худшем случае из-за неё компилятор не сможет полностью пропустить копию.
Ошибка с const: Перемещение от const по производительности абсолютно идентично копированию, поскольку это и есть копирование. Компилятор тихонько выбирает копирующий конструктор, никак об этом не предупреждая. Вот почему так важно профилировать код — даже если он выглядит идеально, работать он может совершенно не так, как вы ожидаете.
Перефразирую в виде реального примера: если вы строите конвейер обработки данных, перемещающий данные от этапа к этапу, то при неверно реализованной семантике перемещения миллисекундная операция может растянуться на 7 миллисекунд. В крупномасштабном сценарии разница такова: можно обработать 1 000 запросов в секунду, а можно ~140 запросов в секунду.
Заключение: как представить себе std::move
Давайте обобщим всё сказанное, чтобы вы могли представлять себе std::move, когда будете писать код.
Расценивайте std::move как следующее обещание компилятору: «Я с этим объектом закончил. Теперь ты волен забирать его ресурсы». Это не инструкция что-то переместить, это разрешение что-то переместить. Само перемещение происходит только тогда, когда его выполнит перемещающий конструктор или оператор перемещающего присваивания.
Применяя std::move(x), вы меняете представление об x в этом выражении (как его видит компилятор). Вы преобразуете его из lvalue (у которого есть постоянное имя и адрес) в xvalue (у которого есть срок годности, и по истечении этого срока его можно пустить в расход). Сама переменная x никуда не девается. Она остаётся в памяти там же, где и была. Вы просто изменили её категорию в системе типов.
Само перемещение, перенос ресурсов, происходит тогда, когда это значение xvalue используется для конструирования или присваивания применительно к другому объекту. То есть, когда вступает в дело перемещающий конструктор или оператор перемещающего присваивания, указатели переставляются, владение переносится, а исходная информация превращается в null.
Чеклист для практики
Вот о чём нужно помнить, когда пишешь код C++ для продакшена:
1. Не использовать std::move с возвращаемыми значениями: по возможности компилятор прибегнет к RVO или NRVO, а если такой возможности не будет — автоматически выполнит перемещение. Если добавить std::move, то выполнить RVO не удастся, и из-за этого всё замедлится.
2. Всегда помечать перемещающие конструкторы и операторы перемещающего присваивания ключевым словом noexcept: без этого стандартные контейнеры не будут использовать ваши операции перемещения при перевыделении памяти. Вместо этого они будут копировать и тем самым угробят производительность вашего кода.
3. Никогда не вызывайте std::move с константными объектами: сами того не видя, вы получите копирование вместо перемещения, и компилятор вас об этом не предупредит. Если что-то является const, то переместить из него ничего нельзя.
4. При реализации перемещения пользуйтесь std::exchange: это самый чистый и наиболее идиоматический способ, которым можно реализовать перемещение. При таком подходе перенос владения организуется явно и очевидно.
5. Не пользуйтесь объектами, подвергшимися перемещению кроме как для того, чтобы присвоить такому объекту новое значение или разрушить его. Считайте их практически мертвыми. Они выпотрошены, хотя, строго говоря, ещё живы.
6. Зарезервируйте std::forward только для работы с шаблонами: в обычном коде используйте std::move. К std::forward прибегайте лишь в тех случаях, когда реализуете прямую передачу в шаблонных функциях, использующих универсальные ссылки.
Реалистичный пример: напишем контейнер, в работе которого используются перемещения
Давайте разберём полномасштабный реалистичный пример, в котором объединим всё изученное здесь. Мы напишем простой класс динамического массива, в котором правильно реализуется семантика перемещения. При этом я объясню каждое из решений, которые принимаются по ходу этой работы.
#include <iostream>
#include <algorithm>
#include <utility>
#include <stdexcept>
template<typename T>
class DynamicArray {
private:
T* data_;
size_t size_;
size_t capacity_;
// Следующий код поможет нам нарастить ёмкость, если это будет необходимо
void reserve_more() {
size_t new_capacity = capacity_ == 0 ? 1 : capacity_ * 2;
T* new_data = new T[new_capacity];
// Перемещаем имеющиеся элементы в новую память
for (size_t i = 0; i < size_; ++i) {
new_data[i] = std::move(data_[i]);
}
delete[] data_;
data_ = new_data;
capacity_ = new_capacity;
}
public:
// Конструктор
DynamicArray() : data_(nullptr), size_(0), capacity_(0) {
std::cout << "Default constructor\n";
}
// Деструктор
~DynamicArray() {
std::cout << "Destructor (size=" << size_ << ")\n";
delete[] data_;
}
// Копирующий конструктор — глубокое копирование
DynamicArray(const DynamicArray& other)
: data_(new T[other.capacity_]),
size_(other.size_),
capacity_(other.capacity_) {
std::cout << "Copy constructor (copying " << size_ << " elements)\n";
std::copy(other.data_, other.data_ + size_, data_);
}
// Копирующее присваивание
DynamicArray& operator=(const DynamicArray& other) {
std::cout << "Copy assignment (copying " << other.size_ << " elements)\n";
if (this != &other) {
// Сначала создаём новые данные (строго гарантируется безопасность исключений)
T* new_data = new T[other.capacity_];
std::copy(other.data_, other.data_ + other.size_, new_data);
// Лишь после успешного выполнения предыдущего кода изменяем состояние
delete[] data_;
data_ = new_data;
size_ = other.size_;
capacity_ = other.capacity_;
}
return *this;
}
// Перемещающий конструктор — перенос владения
DynamicArray(DynamicArray&& other) noexcept
: data_(std::exchange(other.data_, nullptr)),
size_(std::exchange(other.size_, 0)),
capacity_(std::exchange(other.capacity_, 0)) {
std::cout << "Move constructor (transferred " << size_ << " elements)\n";
}
// Перемещающее присваивание
DynamicArray& operator=(DynamicArray&& other) noexcept {
std::cout << "Move assignment (transferred " << other.size_ << " elements)\n";
if (this != &other) {
// Очистка тех ресурсов, с которыми работали
delete[] data_;
// Принимаем владение другими ресурсами
data_ = std::exchange(other.data_, nullptr);
size_ = std::exchange(other.size_, 0);
capacity_ = std::exchange(other.capacity_, 0);
}
return *this;
}
// Добавляем элемент
void push_back(const T& value) {
if (size_ == capacity_) {
reserve_more();
}
data_[size_++] = value;
}
// Добавляем элемент (версия с перемещением)
void push_back(T&& value) {
if (size_ == capacity_) {
reserve_more();
}
data_[size_++] = std::move(value);
}
// Доступ
T& operator[](size_t index) {
if (index >= size_) throw std::out_of_range("Index out of range");
return data_[index];
}
const T& operator[](size_t index) const {
if (index >= size_) throw std::out_of_range("Index out of range");
return data_[index];
}
size_t size() const { return size_; }
size_t capacity() const { return capacity_; }
};
Теперь давайте воспользуемся этим классом и рассмотрим, что именно будет происходить:
int main() {
std::cout << "=== Creating array1 ===\n";
DynamicArray<std::string> array1;
array1.push_back("Hello");
array1.push_back("World");
std::cout << "\n=== Copy construction (array2) ===\n";
DynamicArray<std::string> array2 = array1; // Вызывает копирующий конструктор
std::cout << "\n=== Move construction (array3) ===\n";
DynamicArray<std::string> array3 = std::move(array1); // Вызывает перемещающий конструктор
// данные из array1 уже перенесены и размещены в другом месте – не используйте его!
std::cout << "\n=== Creating array4 for assignment ===\n";
DynamicArray<std::string> array4;
std::cout << "\n=== Copy assignment ===\n";
array4 = array2; // Вызывает копирующее присваивание
std::cout << "\n=== Move assignment ===\n";
array4 = std::move(array3); // Вызывает перемещающее присваивание
// array3 теперь в состоянии «после перемещения»
std::cout << "\n=== Function returning by value ===\n";
auto make_array = []() {
DynamicArray<std::string> temp;
temp.push_back("Temporary");
return temp; // RVO или автоматическое перемещение
};
DynamicArray<std::string> array5 = make_array();
std::cout << "\n=== Destructors will be called ===\n";
return 0;
}
Теперь давайте разберём, что происходит на каждом этапе:
Этап 1: Создание array1
Default constructor
Простая операция конструктора. Массив начинается с нулевого указателя, имеет нулевой размер и нулевую ёмкость.
Этап 2: Копирующее конструирование
Copy constructor (copying 2 elements)
Когда мы пишем DynamicArray<std::string> array2 = array1, мы тем самым явно требуем копирования. Копирующий конструктор выделяет новую память и копирует каждый из элементов. Теперь и array1, и array2 владеют независимыми ресурсами.
Этап 3: Перемещающее конструирование
Move constructor (transferred 2 elements)
А вот тут начинается интересное. std::move(array1) преобразует array1 в xvalue. Перемещающий конструктор это видит и поэтому не выделяет новую память и ничего не копирует, а просто:
Берёт указатель на данные
array1Берёт размер и ёмкость
array1Устанавливает указатель
array1вnullptr, а размер/ёмкость — в 0
Никакого выделения памяти. Никакого копирования. Просто смена указателей. Теперь array3 владеет тем, чем раньше владел array1, а array1 остаётся в действительном, но пустом состоянии.
Этап 4: Копирующее присваивание
Copy assignment (copying 2 elements)
Array4 уже существует (он был сконструирован по умолчанию), поэтому здесь мы прибегаем к присваиванию, а не к конструированию. При копирующем присваивании выделяется новая память, данные копируются, а затем подставляются куда нужно. Обратите внимание: мы выделяем новые данные, прежде, чем удалить старые, тем самым строго гарантируя безопасность исключений. Если выделить память не удастся, то array4 останется в неизменном виде.
Этап 5: Перемещающее присваивание
Move assignment (transferred 2 elements)
Похоже на перемещающее конструирование, но предварительно нужно очистить уже имеющиеся ресурсы array4 (если таковые имеются), а уже потом принимать во владение ресурсы array3. Опять же, никакого выделения памяти, никакого копирования, просто меняем указатели.
Этап 6: Возврат функции
Default constructor
(possibly) Move constructor (transferred 1 elements)
Давайте обсудим, почему выше написано «(possibly)».
Если вы будете компилировать этот код, включив высокую степень оптимизации (например, -O3 в GCC/Clang или /O2 в MSVC), то, вероятно, вообще не увидите в консоли строчку «Move constructor». Будет написано просто «Default constructor» («Конструктор по умолчанию»).
Всё дело в NRVO (оптимизации именованного возвращаемого значения). Компилятор достаточно умён и понимает, что temp внутри лямбда-выражения и array5 вне лямбда-выражения — это логически один и тот же объект. Он полностью пропускает перемещение и конструирует данные именно там, где они в итоге должны оказаться.
Однако, если вы компилируете код в отладочном режиме (когда для упрощения отладки отключены оптимизации), или логика функции настолько сложна, что компилятор её не понимает, то NRVO может и не произойти.
В таком случае C++ гарантирует следующий наиболее приемлемый вариант: автоматическое перемещение. Компилятор неявно трактует возвращённую локальную переменную как rvalue. Он видит return temp; но трактует это как return std::move(temp);
Вывод: вы получаете оптимальную комбинацию. В лучшем случае издержки будут нулевыми (NRVO), а в худшем вы отделаетесь небольшими издержками (на перемещение). За глубокое копирование вам в данном случае платить никогда не придётся.
ПРИМЕЧАНИЕ
Если хотите убедиться, что перемещение происходит и при отключённой NRVO, попробуйте скомпилировать код, выставив в GCC/Clang опцию -fno-elide-constructors on. Вы сразу же увидите, как в консоль выведется перемещающий конструктор.
Ссылка на Compiler explorer с этим примером: https://godbolt.org/z/j4fbxEcWs
Исходный код этого примера выложен на GitHub: https://github.com/ttheghost/SimpleDynamicArray
Чему нас учит этот пример
На этом примере продемонстрировано несколько ключевых принципов:
1. Правило пяти в действии: мы реализовали все пять специальных функций-членов. Если бы мы реализовали лишь некоторые из них, то могли бы столкнуться с сюрпризами в их поведении. Например, если бы мы реализовали деструктор, а копирующий конструктор не реализовали, то сгенерированный компилятором копирующий конструктор делал бы мелкую копию, и мы получим баги, связанные с двойным удалением.
2. noexcept при перемещениях: обратите внимание: обе операции перемещения помечены как noexcept. Это принципиально важно. Без этого, если кто-либо положит наш DynamicArray в std::vector, этот вектор не будет пользоваться нашими операциями перемещения при перевыделении памяти.
3. Использование объектов, из которых произошло перемещение: после std::move(array1) и std::move(array3) данные из этих объектов уже куда-то перенесены. Эти объекты Они по-прежнему существуют (так как не были разрушены), но их ресурсы были перенесены в другое место. Их деструкторы всё равно смогут сработать, но разрушать они будут пустые объекты (nullptr можно удалять без опаски).
4. Строгая гарантия безопасности исключений: обратите внимание: в операторе копирующего присваивания, мы выделяем новую память и копируем в неё данные до того как удалим старую память. Таким образом, если при выделении памяти или при копировании будет выброшено исключение, то наш объект это не затронет. Такой паттерн обычен в C++, застрахованном от исключений.
5. Перегрузка rvalues: обратите внимание, что у нас есть две версии push_back, одна принимает const T& (для левых значений), а одна принимает T&& (для правых значений). При вызове push_back с временными данными выбирается перегрузка правого значения (rvalue) и мы можем переместить временные данные в массив. При вызове его с именованной переменной, то выбирается перегрузка левого значения (lvalue), и мы копируем.
Неприятности с производительностью: когда семантика перемещения даёт сбои
Давайте рассмотрим некоторые тонкие проблемы с производительностью, которые могут возникать даже в тех случаях, когда вам кажется, что вы используете семантику перемещения правильно.
Неприятность 1: при оптимизации коротких строк (SSO) перемещения уже не бесплатные
В современных реализациях стандартной библиотеки при работе с std::string применяется оптимизация коротких строк. Строки меньше определённого размера (как правило, 15-23 символа) хранятся непосредственно в строковом объекте, а не в куче.
std::string small = "Hi"; // хранится во встроенном виде, память в куче не выделяется
std::string large = "This is a much longer string that definitely needs heap allocation";
std::string moved_small = std::move(small); // копирует буфер встраивания
std::string moved_large = std::move(large); // просто меняет указатель
При перемещении короткой строки вы фактически копируете маленький буфер фиксированного размера. Эта операция быстрее, чем выделение памяти в куче, но она обходится не «бесплатно» в отличие от перемещения большой строки. Данные, перемещённые из короткой строки, всё равно остаются в действительном состоянии (но пустуют).
Урок: перемещения бывают и не бесплатны. При работе с малыми объектами, которые хранятся во встроенном виде, перемещение, в сущности, эквивалентно копированию. Это всё равно нормально, так и задумано, но важно понимать, что происходит на самом деле.
Неприятность 2: если забыли о перемещении в циклах
std::vector<std::string> source = getLargeStrings();
std::vector<std::string> dest;
for (const auto& s : source) { // const-ссылка = перемещение невозможно
dest.push_back(s); // Всегда копирует
}
Здесь const предотвращает перемещение. Даже если вы хотите что-то переместить из источника, вы этого сделать не сможете, поскольку const-ссылки не могут привязываться к rvalue-ссылкам. Вот так будет правильно:
for (auto& s : source) { // Неконстантная ссылка
dest.push_back(std::move(s)); // Теперь можно перемещать
}
// Обратите внимание: строки из источника теперь в перемещённом состоянии (вероятно, они пусты)
Но имейте в виду: после этого цикла source продолжает существовать и по-прежнему содержит строки, но все они уже в состоянии "после перемещения данных" (и обычно пустуют). Если вы действительно закончили работу с исходным объектом, то это нормально. А если исходный объект вам ещё может понадобиться, то перед нами баг.
Вот как лучше сделать, если вы хотите потреблять источник:
std::vector<std::string> dest = std::move(source); // просто переместите весь вектор целиком
// теперь источник пуст, всей информацией владеет объект в точке назначения
Так перемещается сам вектор (достаточно поменять лишь несколько указателей), а не отдельные строки. Получается гораздо эффективнее.
Неприятность 3: Перемещение сквозь несколько слоёв
void process(std::string s) { // Принимает по значению
consume(s); // Ой! Копирует, а не перемещает
}
std::string data = "important";
process(std::move(data)); // Мы переместили информацию в процесс, но процесс копирует информацию, чтобы её потреблять
Перемещение позволяет нам эффективно зайти в process, но затем мы копируем в consume. Если мы хотим, чтобы перемещение просачивалось, то:
void process(std::string s) {
consume(std::move(s)); // Теперь мы перемещаем для потребления
}
Это безопасно, поскольку s передаётся по значению, мы владеем копией и можем делать с ней всё, что захотим. После перемещения от s мы её больше не используем (функция заканчивается).
Урок: перемещения не просачиваются автоматически. Каждый вызов функции — это новая возможность либо переместить, либо копировать. Когда всё сделаете с переменной, явно опишите всё, что связано с std::move.
Неприятность 4: Случайные копии в операторах возврата
std::pair<std::string, std::string> getData() {
std::string a = "first";
std::string b = "second";
return {a, b}; // Копии! Не перемещения!
}
Инициализирующий список в фигурных скобках {a, b} создаёт временную пару и копирует в неё a и b. Затем этот временный элемент перемещается или через пропуск копии переходит в возвращаемое значение. Мы потратились на две копии, которые нам не нужны.
Лучше сделать так:
std::pair<std::string, std::string> getData() {
std::string a = "first";
std::string b = "second";
return {std::move(a), std::move(b)}; // Теперь мы перемещаем в пару
}
Это один из тех редких случаев, когда использовать std::move в операторе возврата правильно. Дело в том, что мы не перемещаем возвращаемое значение как таковое, а перемещаем элементы в тот объект, который конструируем для возврата.
Семантика перемещения при наследовании
Если вы работаете с наследованием, то с семантикой перемещения следует обращаться аккуратно:
class Base {
std::string base_data_;
public:
Base(Base&& other) noexcept
: base_data_(std::move(other.base_data_)) {}
};
class Derived : public Base {
std::string derived_data_;
public:
Derived(Derived&& other) noexcept
: Base(std::move(other)), // Необходимо явно переместить base
derived_data_(std::move(other.derived_data_)) {}
};
Обратите внимание на Base(std::move(other)) в перемещающем конструкторе Derived. Даже притом, что other — это rvalue-ссылка, она является lvalue, когда используется как выражение (так как имеет имя!). Без std::move, мы бы вызвали для Base копирующий, а не перемещающий конструктор.
Самое важное: внутри производного конструктора перемещения other — это lvalue, даже хотя и имеет тип Derived&&. Это правило «именованная rvalue-ссылка является lvalue» с непривычки всех запутывает.
Заключение: философия в основе семантики перемещения
Наконец, расскажу вам о более глубоком смысле, заложенном в семантике перемещения. До стандарта C++11 в языке C++ существовала фундаментальная проблема: приходилось выбирать между эффективностью и безопасностью.
Эффективность: передача значений по указателю, управление владением вручную, риск утечек памяти и висячих указателей.
Безопасность: везде использовать глубокое копирование, расплачиваться за это снижением производительности.
С семантикой перемещения у нас появился и третий вариант: безопасно и эффективно передавать владение. Семантика перемещения обеспечивает такую же безопасность, как и глубокое копирование (компилятор всё отслеживает, не требуется никакого ручного управления), но работа идёт так же быстро, как и при манипуляции указателями (никакого глубокого копирования, просто перестановка указателей).
В данном случае наиболее важно понять, что многие объекты могут существовать в состоянии «живого трупа»: они либо стоят в очередь на разрушение, либо мы уже сделали с ними всё, что хотели. Семантика перемещения просто позволяет вытянуть значение из этих обречённых объектов. Не давая их ресурсам умереть вместе со своими объектами, мы переносим эти ресурсы к тем объектам, которые ещё будут их использовать.
std::move — это явный маркер такого переноса. Тем самым вы сообщаете компилятору: «Мне этот объект больше не нужен. Он не жилец. Можно забирать у него органы и передавать тем, кто в них нуждается».
Вот почему правильно представлять эту модель важнее, чем просто выучить её синтаксис. Семантика перемещения — это не просто уловка для повышения производительности, а фундаментально новый подход к трактовке времени жизни объекта и к владению ресурсами в C++. Если глубоко во всём этом разбираться, то вы станете не просто быстрее писать на C++, но и грамотнее рассуждать об управлении ресурсами на любом языке.
Дополнительное чтение и источники
Если вы хотите ещё подробнее изучить семантику перемещения и связанные с ней темы, то вот вам несколько отличных ресурсов:
Официальная документация
cppreference: std::move – Подробная справка с примерами
cppreference: категории значений — Подробное объяснение значений lvalue, rvalue, xvalue и т.д.
cppreference: пропуск копии — Когда и как компиляторы пропускают операции копирования и перемещения
cppreference: перемещающие конструкторы — Полная спецификация
Предложения в стандарт
P1144: Object relocation — Предложение Артура О’Дуайера по реализации тривиальной релокации
P2786: Trivial relocatability for C++26 — альтернативный подход, добавленный в повестку совещания по новому стандарту
Статьи и лекции
Understanding when not to std::move (Red Hat Developer Blog) — с акцентом на распространённых ошибках
«Back to Basics: Move Semantics» — множество выступлений с CppCon, объясняющих эти концепции в разном ракурсе
Книги
«Эффективный и современный C++» Скотта Мейерса – в разделах 23-25 подробно рассматривается семантика перемещения
«C++ Move Semantics - The Complete Guide» Николаи Джосаттиса — целая книга, посвящённая этой теме
Помните: семантика перемещения — это инструмент, а не цель. Цель — это писать корректный и эффективный код, который удобно поддерживать. Семантика перемещения помогает добиться этой цели, но только, если использовать её правильно. Иногда вам требуется именно копия. Иногда компилятор полностью убирает некоторую операцию. Если понимать, как и когда использовать перемещения, а когда этого делать не нужно, то у вас получится не просто хороший, а отличный код на C++.