std::move vs. std::forward
Несмотря на то, что материалов на тему move-семантики и идеальной передачи в Интернете предостаточно, вопросов типа «что я должен здесь использовать: move или forward?» не становится меньше или мне просто «везет» на них. Поэтому и решено было написать эту статью. Предполагается, что читатель хотя бы немного знаком с rvalue-ссылками, move-семантикой и идеальной передачей.
Для чего нужен шаблон функции std: move?
Функция std: move выполняет приведение передаваемого lvalue-аргумента в rvalue-ссылку. Зачем это нужно? Вернемся во времена С++98 и рассмотрим классический пример:
template
swap(T& a, T& b)
{
T tmp(a); // здесь две копии a
a = b; // здесь две копии b
b = tmp; // здесь две копии a
}
В этом примере для встроенных типов проблем не возникает, но для таких типов как vector или string копирование может являться крайне дорогой операцией. Необходимо было добавлять специализации для шаблона swap, чтобы выполнять это действие более эффективно, и многих такой подход не устраивал.
Что же здесь нужно изменить? Нам не нужно выполнять копирование, а нужно «перемещать» объекты. Как это сделать? Нужно вызывать специальный конструктор или оператор присваивания, которые не будут копировать содержимое классов, а будут обмениваться им (по сути выполнять swap для всех членов).
Для этого в C++11 ввели rvalue-ссылки, которые обозначаются через && и позволяют ссылаться на временные объекты или определять объекты как «перемещаемые». Также появились конструктор перемещения (move constructor) и оператор перемещающего присваивания (move assignment), которые отличаются от копирующих «коллег» тем, что в качестве аргумента принимают неконстантную rvalue-ссылку:
template class vector {
// ...
vector(const vector&); // copy constructor
vector(vector&&) noexcept; // move constructor
vector& operator=(const vector&); // copy assignment
vector& operator=(vector&&); // move assignment
};
Теперь операцию swap мы можем переписать с использованием функции std: move, которая возвращает rvalue-ссылку и тем самым сообщает компилятору, что параметр является перемещаемым:
template
void swap(T& a, T& b)
{
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
Функция std: move не выполняет никаких перемещений. Как уже было сказано выше, она выполняет приведение типа к rvalue-ссылке. Давайте посмотрим на ее код:
// FUNCTION TEMPLATE move
template
[[nodiscard]] constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
return static_cast&&>(_Arg);
}
То есть это просто обертка для static_cast, которая «убирает» ссылку у переданного аргумента с помощью remove_reference_t и, добавив &&, преобразует тип в rvalue-ссылку. Давайте глянем на то, как именно remove_reference_t избавляется от ссылок:
// STRUCT TEMPLATE remove_reference
template
struct remove_reference {
using type = _Ty;
using _Const_thru_ref_type = const _Ty;
};
template
struct remove_reference<_Ty&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&;
};
template
struct remove_reference<_Ty&&> {
using type = _Ty;
using _Const_thru_ref_type = const _Ty&&;
};
template
using remove_reference_t = typename remove_reference<_Ty>::type;
Для чего нужен std: forward?
Функция std: forward, как известно, применяется при идеальной передаче (perfect forwarding).
Идеальная передача позволяет создавать функции-обертки, передающие параметры без каких-либо изменений (lvalue передаются как lvalue, а rvalue — как rvalue) и тут std: move нам не подходит, так как она безусловно приводит свой результат к rvalue.
Поэтому, была разработана функция std: forward, которая выполняет примерно следующую работу:
template
T&& forward(T&& param)
{
if (is_lvalue_reference::value)
return param;
else
return move(param);
}
То есть, если ссылка была передана как rvalue, то вызываем духов std: move, а иначе просто возвращаем то, что передали.
Если посмотреть исходники стандартной библиотеки у MS, то мы увидим следующую реализацию:
// FUNCTION TEMPLATE forward
template
[[nodiscard]] constexpr _Ty&& forward(
remove_reference_t<_Ty>& _Arg) noexcept { // forward an lvalue as either an lvalue or an rvalue
return static_cast<_Ty&&>(_Arg);
}
template
[[nodiscard]] constexpr _Ty&& forward(
remove_reference_t<_Ty>&& _Arg) noexcept { // forward an rvalue as an rvalue
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
return static_cast<_Ty&&>(_Arg);
}
Может быть немного сложнее, но смысл тот же. И пусть Вас не пугает перегруженная версия forward (remove_reference_t<_Ty>&& _Arg). В ней просто добавлена проверка на случай компиляции чудесатых конструкций вроде такой:
func(std::forward(7));
На стадии компиляции получим сразу ошибку: bad forward call. И я полагаю, это единственное, ради чего добавлена еще одна сигнатура, поскольку нет никакого смысла городить, например, такие конструкции:
bar(std::forward(1));
Если у кого-то есть идеи, для чего еще может понадобиться forward (remove_reference_t<_Ty>&& _Arg), дайте знать и заранее спасибо.
Возможно сейчас у читателя возникает в голове вопрос:, а может на самом деле нет нужды вызывать forward и просто вызывать static_cast<_Ty&&>(_Arg)? Да можно, конечно, просто forward более безопасен в плане очепяток и путаниц. Кроме того, forward
static_cast&&>(Arg);
Пример
#include
using namespace std;
template
void bar(const T& v) { cout << "by const ref" << endl; }
template
void bar(T& v) { cout << "by lvalue ref" << endl; }
template
void bar(T&& v) { cout << "by rvalue ref" << endl; }
// FUNCTION TEMPLATE forward
template
[[nodiscard]] constexpr _Ty&& _forward(
remove_reference_t<_Ty>& _Arg) noexcept
{
cout << "forward an lvalue as either an lvalue or an rvalue" << endl;
return static_cast<_Ty&&>(_Arg);
}
template
[[nodiscard]] constexpr _Ty&& _forward(
remove_reference_t<_Ty>&& _Arg) noexcept
{
static_assert(!is_lvalue_reference_v<_Ty>, "bad forward call");
cout << "forward an rvalue as an rvalue" << endl;
return static_cast<_Ty&&>(_Arg);
}
// FUNCTION TEMPLATE move
template
[[nodiscard]] constexpr remove_reference_t<_Ty>&& _move(_Ty&& _Arg) noexcept
{
cout << "forward _Arg as movable" << endl;
return static_cast&&>(_Arg);
}
template
void foo(T&& p)
{
bar(p);
bar(_move(p));
bar(_forward(p));
}
int main()
{
int i = 0;
foo(i); // lvalue: T - int&, p - int&
foo(0); // rvalue: T - int, p - int&&
}
Я скопировал код move и forward, вставив в них вывод в cout, чтобы можно было увидеть, что же происходит. Далее мы берем и последовательно вызываем функцию foo, передавая сперва lvalue значение, а затем rvalue. Обратите внимание, что у функции foo аргумент является «универсальной ссылкой» в терминологии Скотта Мейерса, или передаваемой ссылкой (forwarding reference) в терминологии комитета стандартизации С++, то есть она:
Выводимая
Имеет вид T&&
Внутри функции foo, вызываем перегруженную функцию bar для константной lvalue-ссылки, для lvalue-ссылки и, наконец, для «универсальной» ссылки.
На выводе получаем:
by lvalue ref
forward _Arg as movable
by rvalue ref
forward an lvalue as either an lvalue or an rvalue
by lvalue ref
by lvalue ref
forward _Arg as movable
by rvalue ref
forward an lvalue as either an lvalue or an rvalue
by rvalue ref
Итак, разбираем lvalue. В функции foo тип T будет выведен как int&. Почему T выводится как lvalue-сылка, т.е. как int&? По правилам вывода аргумента шаблона для универсальных ссылок, если в качестве аргумента передано lvalue значение, то T выводится как lvalue-ссылка. Согласно правилам сжатия ссылок (reference collapsing) аргумент p также будет выведен как int&, так как foo (int& && p) превратится в foo (int& p). Таким образом, мы получаем следующую версию foo:
void foo(int& p)
{
bar(p);
bar(_move(p));
bar(_forward(p));
}
bar (p); вызовет bar (int& v), и тут вопросов возникать не должно.
bar (_move (p)); сперва вызовет move, которая вернет rvalue-ссылку, а значит будет вызвана bar (int&& v).
bar (_forward
constexpr int& _forward(int& _Arg) noexcept
{
return static_cast(_Arg);
}
Разберем теперь rvalue. В функции foo тип T будет выведен как int. Согласно все тех же правил вывода аргумента шаблона для универсальных ссылок, если в качестве аргумента передано rvalue, то T выводится как бессылочный тип. Это необходимо для корректной работы функции forward, как мы это увидим далее. Итак, наша foo выглядит теперь следующим образом:
void foo(int p)
{
bar(p);
bar(_move(p));
bar(_forward(p));
}
bar (p); как и ранее, вызовет bar (int& v).
bar (_move (p)); как и ранее, сперва вызовет move, которая вернет rvalue-ссылку, а значит будет опять вызвана bar (int&& v).
Теперь посмотрим как же будет инстанцирована forward
constexpr int&& _forward(int& _Arg) noexcept
{
return static_cast(_Arg);
}
Давайте подставим вместо move и forward их содержимое, чтобы наглядно посмотреть на разницу:
// lvalue
void foo(int& p)
{
bar(p);
bar(static_cast(p));
bar(static_cast(p));
}
// rvalue
void foo(int p)
{
bar(p);
bar(static_cast(p));
bar(static_cast(p));
}
Вывод: для перемещаемых объектов необходимо использовать std: move, а для идеальной передачи — std: forward.
Примеры использования move и forward
Кроме приведенного выше примера std: swap, использование std: move можно найти в различных алгоритмах, где нужно менять элементы местами (различные сортировки, или, например, в функции std: unique).
Если необходимо «передать» умный указатель std: unique_ptr, то сделать мы можем это только через std: move (либо через release () и сырой указатель, но это не по фень-шую).
В функции std: vector: push_back для rvalue можно обнаружить:
void push_back(_Ty&& _Val) {
// insert by moving into element at end, provide strong guarantee
emplace_back(std::move(_Val));
}
Таким образом, legacy-код, добавляющий новый элемент в вектор через rvalue волшебным образом начинает работать через перемещение, а не копирование.
Если ваша функция возвращает кортеж (или пару), то стоит обратить внимание на возможность перемещения некоторых или даже всех его элементов:
std::pair get_my_data(const size_t index)
{
some_type my_data;
bool is_valid;
// do something
return { std::move(my_data), is_valid };
}
Обратите внимание на то, что не нужно использовать std: move при возврате из функции, возвращающий локальный объект:
std::string get_my_string(const size_t index)
{
std::string my_string;
// do something
return std::move(my_string); // wrong!
}
Здесь нужно убрать std: move. Всю работу сделает copy/move elision — специальная оптимизация, которую выполняет компилятор, убирая лишние создания объектов.
Функция std: forward и вариативные шаблоны являются фундаментом, на котором строятся такие функции-обертки как std: makeunique, std: makeshared, std: makepair, std: maketuple и другие. Например, make_unique делает очень простую работу:
template , int> = 0>
[[nodiscard]] unique_ptr<_Ty> make_unique(_Types&&... _Args) { // make a unique_ptr
return unique_ptr<_Ty>(new _Ty(std::forward<_Types>(_Args)...));
}
Семейство emplace методов также работает через forward, зачастую просто вызывая конструктор через placement-new.
Что еще можно почитать на эту тему:
Идеальная передача и универсальные ссылки в C++
Книга Скотта Мейерса «Эффективный и современный С++. 42 рекомендации по использованию C++11 и C++14»