Как стать автором
Обновить

std::move vs. std::forward

Время на прочтение 8 мин
Количество просмотров 40K

Несмотря на то, что материалов на тему move-семантики и идеальной передачи в Интернете предостаточно, вопросов типа «что я должен здесь использовать: move или forward?» не становится меньше или мне просто «везет» на них. Поэтому и решено было написать эту статью. Предполагается, что читатель хотя бы немного знаком с rvalue-ссылками, move-семантикой и идеальной передачей.


Для чего нужен шаблон функции std::move?

Функция std::move выполняет приведение передаваемого lvalue-аргумента в rvalue-ссылку. Зачем это нужно? Вернемся во времена С++98 и рассмотрим классический пример:

template<class T>
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 T> 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<class T>
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 <class _Ty>
[[nodiscard]] constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept { // forward _Arg as movable
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

То есть это просто обертка для static_cast, которая «убирает» ссылку у переданного аргумента с помощью remove_reference_t и, добавив &&, преобразует тип в rvalue-ссылку. Давайте глянем на то, как именно remove_reference_t избавляется от ссылок:

// STRUCT TEMPLATE remove_reference
template <class _Ty>
struct remove_reference {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty;
};

template <class _Ty>
struct remove_reference<_Ty&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&;
};

template <class _Ty>
struct remove_reference<_Ty&&> {
    using type                 = _Ty;
    using _Const_thru_ref_type = const _Ty&&;
};

template <class _Ty>
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<typename T>
T&& forward(T&& param)
{
    if (is_lvalue_reference<T>::value)
        return param;
    else
        return move(param);
}

То есть, если ссылка была передана как rvalue, то вызываем духов std::move, а иначе просто возвращаем то, что передали.

Если посмотреть исходники стандартной библиотеки у MS, то мы увидим следующую реализацию:

// FUNCTION TEMPLATE forward
template <class _Ty>
[[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 <class _Ty>
[[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<int&>(7));

На стадии компиляции получим сразу ошибку: bad forward call. И я полагаю, это единственное, ради чего добавлена еще одна сигнатура, поскольку нет никакого смысла городить, например, такие конструкции:

bar(std::forward<int>(1));

Если у кого-то есть идеи, для чего еще может понадобиться forward(remove_reference_t<_Ty>&& _Arg), дайте знать и заранее спасибо.

Возможно сейчас у читателя возникает в голове вопрос: а может на самом деле нет нужды вызывать forward и просто вызывать static_cast<_Ty&&>(_Arg)? Да можно, конечно, просто forward более безопасен в плане очепяток и путаниц. Кроме того, forward<T>(Arg) в коде выглядит более осмысленным и понятным, нежели static_cast<T&&>(Arg). Никто же не любит писать комментарии, поэтому лучше писать сразу хорошо читаемый код. То же самое касается и move, и тут придется писать более громоздкую конструкцию:

static_cast<remove_reference_t<T>&&>(Arg);

Пример

#include <iostream>

using namespace std;

template<typename T>
void bar(const T& v) { cout << "by const ref" << endl; }

template<typename T>
void bar(T& v) { cout << "by lvalue ref" << endl; }

template<typename T>
void bar(T&& v) { cout << "by rvalue ref" << endl; }

// FUNCTION TEMPLATE forward
template <class _Ty>
[[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 <class _Ty>
[[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 <class _Ty>
[[nodiscard]] constexpr remove_reference_t<_Ty>&& _move(_Ty&& _Arg) noexcept
{
    cout << "forward _Arg as movable" << endl;
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

template<typename T>
void foo(T&& p)
{
    bar(p);
    bar(_move(p));
    bar(_forward<T>(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) в терминологии комитета стандартизации С++, то есть она:

  1. Выводимая

  2. Имеет вид 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<int&>(p));
}

bar(p); вызовет bar(int& v), и тут вопросов возникать не должно.

bar(_move(p)); сперва вызовет move, которая вернет rvalue-ссылку, а значит будет вызвана bar(int&& v).

bar(_forward<int&>(p)); сперва вызовет _forward(int& _Arg), которая должна вернуть lvalue-ссылку, и значит будет вызвана опять bar(int& v). Что при этом произойдет внутри _forward? Применив правила сжатия ссылок, мы получим следующую версию _forward:

constexpr int& _forward(int& _Arg) noexcept
{
    return static_cast<int&>(_Arg);
}

Разберем теперь rvalue. В функции foo тип T будет выведен как int. Согласно все тех же правил вывода аргумента шаблона для универсальных ссылок, если в качестве аргумента передано rvalue, то T выводится как бессылочный тип. Это необходимо для корректной работы функции forward, как мы это увидим далее. Итак, наша foo выглядит теперь следующим образом:

void foo(int&& p)
{
    bar(p);
    bar(_move(p));
    bar(_forward<int>(p));
}

bar(p); как и ранее, вызовет bar(int& v), т.к. p является l-value, не смотря на то, что выглядит как r-value ссылка.

bar(_move(p)); как и ранее, сперва вызовет move, которая вернет rvalue-ссылку, а значит будет опять вызвана bar(int&& v).

Теперь посмотрим как же будет инстанцирована forward<int>:

constexpr int&& _forward(int& _Arg) noexcept
{
    return static_cast<int&&>(_Arg);
}

Давайте подставим вместо move и forward их содержимое, чтобы наглядно посмотреть на разницу:

// lvalue
void foo(int& p)
{
    bar(p);
    bar(static_cast<int&&>(p));
    bar(static_cast<int&>(p));
}

// rvalue
void foo(int p)
{
    bar(p);
    bar(static_cast<int&&>(p));
    bar(static_cast<int&&>(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<some_type, bool> 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::make_unique, std::make_shared, std::make_pair, std::make_tuple и другие. Например, make_unique делает очень простую работу:

template <class _Ty, class... _Types, enable_if_t<!is_array_v<_Ty>, 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»

Теги:
Хабы:
+16
Комментарии 36
Комментарии Комментарии 36

Публикации

Истории

Работа

Программист C++
121 вакансия
QT разработчик
13 вакансий

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн
PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн