В этой статье разобраны основные преимущества и нюансы move семантики в C++11 и старше. Всё описанное в этой статье было проверено на компиляторе Clang 6.0.1 с библиотекой libc++ на x86, GNU/Linux.
Введение в move
Move семантика позволяет переместить объект вместо его копирования для увеличения производительности. Проще всего понять семантику перемещения на примере. В качестве этого примера будет использоваться класс String
:
class String
{
public:
explicit String(const char *const c_string)
{
std::cout << "String(const char *const c_string)\n";
size = strlen(c_string) + 1;
this->c_string = new char[size];
strcpy(this->c_string, c_string);
}
String(const String& other)
{
std::cout << "String(const String& other)\n";
c_string = new char[other.size];
strcpy(c_string, other.c_string);
size = other.size;
}
~String() noexcept
{
std::cout << "~String()\n";
delete[] c_string; // delete на nullptr не даёт никакого эффекта
}
private:
char *c_string;
size_t size;
};
При передаче объекта этого класса в функцию, принимающую его по значению, — назовём её by_value()
— произойдёт следующее:
auto string = String("Hello, C++11");
by_value(string); // копирование string в by_value()
stdout:
String(const char *const c_string) // new[]
String(const String& other) // new[]
~String() // delete[]
~String() // delete[]
Получается 4 обращения к аллокатору, что достаточно накладно. Но если объект String
больше не понадобится, а функцию by_value()
менять нельзя, то можно переместить объект, а не копировать. Для этого необходимо написать конструктор перемещения для класса String
:
String(String &&other) noexcept
{
std::cout << "String(String&& other)\n";
c_string = other.c_string;
size = other.size;
other.c_string = nullptr;
other.size = 0;
}
Параметр конструктора перемещения other
, во-первых, неконстантный, т.к. конструктор его изменяет; во-вторых, является rvalue
-ссылкой (&&
), а не lvalue
-ссылкой (&
). Об их отличиях будет сказано далее. Сам конструктор переносит Си-строку с other
на this
, делая other
пустым.
Конструктор перемещения в общем случае не медленнее, а зачастую даже быстрее конструктора копирования, но ничего не мешает программисту поместить sleep(10'000)
в конструктор перемещения.
Для вызова конструктора перемещения вместо конструктора копирования можно использовать std::move()
. Теперь пример выглядит следующим образом:
auto string = String("Hello, C++11");
by_value(std::move(string)); // перемещение string в by_value(), string теперь пустая
stdout:
String(const char *const c_string) // new[]
String(String&& other) // благодаря замене на конструктор перемещения, пропал new[]
~String() // delete[]
~String() // delete[] на nullptr
Количество обращений к аллокатору уменьшилось вдвое!
rvalue и lvalue
Основное отличие rvalue
от lvalue
в том, что объекты rvalue
могут быть перемещены, тогда как объекты lvalue
всегда копируются.
Это «могут быть» лучше всего демонстрируют следующие два примера:
class TextView
{
public:
explicit TextView(const String string)
: text(std::move(string)) // stdout: String(const String& other)
{}
private:
String text;
};
Этот код работает не так, как от него ожидается. std::move()
всё ещё конвертирует lvalue
в rvalue
, но конвертация сохраняет все модификаторы, в том числе и const
. Затем компилятор выбирает среди двух конструкторов класса String
самый подходящий. Из-за того что компилятор не может отправить const rlvaue
туда, где ожидается non-const rvalue
, он выбирает конструктор копирования, и const rvalue
конвертируется обратно в const lvalue
. Вывод: следите за модификаторами объектов, т.к. они учитываются при выборе одной из перегрузок функции.
Второй пример демонстрирует правило: аргументы и результат функции могут быть как rvalue
, так и lvalue
, но параметры функций могут быть только lvalue
. Аргумент — это то, что передаётся в функцию. Он инициализирует параметр, который доступен непосредственно внутри функции.
void f(String&& string)
{
g(string); // Clang: no known conversion from 'String' to 'String &&' for 1st argument
}
void g(String&& string) {}
Хотя параметр string
функции f()
и имеет тип rvalue
-ссылки, он является lvalue
и требует явной конвертации в rvalue
перед передачей в функцию g()
. Этим и занимается std::move()
.
Универсальные ссылки
Универсальные ссылки могут быть как rvalue
-, так и lvalue
-ссылкой в зависимости от аргументов или результата функции. Используются в шаблонах и в auto&&
:
template <class T = String>
void template_func(T&& string)
{
by_value(std::forward<T>(string));
}
// template_func() принимает любую Xvalue
template_func(string); // String(const String& other)
template_func(std::move(string)); String(String&& other)
Компилятор на основе этого шаблона генерирует 2 функции, одна из которых принимает lvalue
, другая — rvalue
, если они будут использоваться. Если программист хочет использовать перемещение для rvalue
-ссылки и простое копирование для lvalue
-ссылки, то он может использовать std::forward()
, который приводит свой аргумент к rvalue
только тогда, когда его тип является rvalue
-ссылкой. std::forward()
требует явного указания шаблонного параметра даже с автоматическим выводом шаблонов в С++17.
Универсальная ссылка обязана быть шаблонным параметром (отсюда такое странное определение шаблона функции в примере) в формате T&&
. Например, std::vector<T>&&
уже не универсальная, а rvalue
-ссылка.
Свёртывание ссылок
На самом деле разницы между rvalue
-ссылкой и универсальной ссылкой нет. Универсальная ссылка — лишь удобная абстракция над rvalue
-ссылкой, которой многие пользуются. Но как тогда rvalue
-ссылка превращается в lvalue
-ссылку при lvalue
аргументе? Всё дело в свёртывании ссылок.
При вызове template_func(string)
компилятор генерирует следующий заголовок функции:
void template_func(String& && string);
И получается ссылка на ссылку! Вручную так сделать нельзя, но шаблоны могут. Далее компилятор свёртывает ссылку на ссылку. Свертывание производится по следующему правилу: результатом свертывания будет rvalue
-ссылка только тогда, когда обе ссылки являются rvalue
-ссылками.
Именно из-за этого безобразия проще использовать абстракцию универсальных ссылок.
Подробнее о move семантике и
rvalue
можно узнать из книги С. Мейерса «Эффективный и современный С++: 42 рекомендации по использованию С++ 11 и С++14» ISBN: 978-5-8459-2000-3.
Copy/move elision
Copy/move elision — это оптимизация, при которой компилятор может убрать некоторые вызовы конструктора копирования и деструктора, но в данный момент только при возврате объекта из функции, и только если тип возвращаемого объекта полностью совпадает с типом функции.
Поэтому при возврате из функции использование std::move()
может снизить производительность, ограничив компилятор в Copy elision оптимизации, ведь отсутствие конструктора быстрее, чем конструктор перемещения.
String non_copy_elision()
{
return std::move(String(""));
}
Здесь std::move()
только замедляет код, добавляя лишний вызов String(String&& other)
и ~String()
.
В C++20 copy/move elision может быть расширен, благодаря чему в некоторых случаях использование std::move()
также снизит производительность. Подробнее о расширении copy/move elision можно узнать из видеозаписи со встречи рабочей группы по стандартизации С++ в московском офисе Яндекса.
Иван Борисов