Основные цели std::forward и решаемые им проблемы
Описание проблемы:
Я изучаю концепцию идеальной передачи (perfect forwarding) в C++ и столкнулся с вопросом о том, как работает функция std::forward
. В приведенном ниже коде std::forward
используется для преобразования именованных rvalue-ссылок t1
и t2
в безымянные rvalue-ссылки:
template <typename T1, typename T2>
void outer(T1&& t1, T2&& t2)
{
inner(std::forward<T1>(t1), std::forward<T2>(t2));
}
Какова цель этого преобразования? Как это повлияет на вызываемую функцию inner
, если оставить t1
и t2
в виде lvalue-ссылок?
Дополнительно, интересно, как это изменение типов параметров влияет на семантику передачи значений. Буду признателен за подробные объяснения и примеры.
5 ответ(ов)
В perfect forwarding функция std::forward
используется для преобразования именованных rvalue-ссылок t1
и t2
в безымянные rvalue-ссылки. Какова цель такого преобразования? Как это повлияет на вызываемую функцию inner
, если оставить t1
и t2
как lvalue?
Дело в том, что при использовании именованной rvalue-ссылки в выражении она на самом деле считается lvalue (поскольку вы обращаетесь к объекту по имени). Рассмотрим следующий пример:
void inner(int &, int &); // #1
void inner(int &&, int &&); // #2
Теперь, если мы вызовем outer
следующим образом:
outer(17, 29);
мы хотим, чтобы 17 и 29 были переданы в #2, так как 17 и 29 являются литералами типа int и, следовательно, rvalue. Однако поскольку t1
и t2
в выражении inner(t1, t2);
являются lvalue, то мы вызовем #1 вместо #2. Именно поэтому нам нужно вернуть ссылки обратно в безымянные ссылки с помощью std::forward
. Таким образом, t1
в outer
всегда является lvalue-выражением, в то время как std::forward<T1>(t1)
может быть rvalue в зависимости от T1
. Последнее является lvalue-выражением только в том случае, если T1
- это lvalue-ссылка. И T1
будет выведено как lvalue-ссылка только в том случае, если первый аргумент в outer
был lvalue-выражением.
Если после инстанцирования T1
будет иметь тип char
, а T2
будет классом, и вы хотите передать t1
по значению, а t2
по const
ссылке, то это вполне приемлемо, если функция inner()
не принимает их по неконстантной ссылке. В противном случае, если inner()
принимает параметры именно так, то вам также следует делать то же самое.
Попробуйте написать набор функций outer()
, которые реализуют это без rvalue-ссылок, выведя правильный способ передачи аргументов в зависимости от типов inner()
. Я думаю, вам потребуется около 2^2 вариаций, что потребует довольно сложной темплейтной метапрограммирования для корректного определения аргументов и много времени, чтобы всё это правильно настроить для всех случаев.
А потом кто-то приходит с функцией inner()
, которая принимает аргументы по указателю. Теперь у нас получается 32 (или 42. Честно говоря, мне лень считать, повлияет ли const
указатель на итог).
А теперь представьте, что вы хотите сделать это для пяти параметров. Или семи.
Теперь вы понимаете, почему некоторые умные умы придумали "идеальную переадресацию": это позволяет компилятору выполнять всю эту работу за вас.
Важный момент, который не был четко обозначен, заключается в том, что static_cast<T&&>
корректно обрабатывает также и const T&
.
Рассмотрим приведённый вами код:
#include <iostream>
using namespace std;
void g(const int&)
{
cout << "const int&\n";
}
void g(int&)
{
cout << "int&\n";
}
void g(int&&)
{
cout << "int&&\n";
}
template <typename T>
void f(T&& a)
{
g(static_cast<T&&>(a));
}
int main()
{
cout << "f(1)\n";
f(1);
int a = 2;
cout << "f(a)\n";
f(a);
const int b = 3;
cout << "f(const b)\n";
f(b);
cout << "f(a * b)\n";
f(a * b);
}
Результат выполнения программы будет:
f(1)
int&&
f(a)
int&
f(const b)
const int&
f(a * b)
int&&
Как видно из вывода, функция g
вызывается с нужными аргументами в зависимости от переданного типа. Шаблонная функция f
обрабатывает аргументы таким образом, что при наличии rvalue (например, f(1)
или f(a * b)
) передается int&&
, а при передаче lvalue (например, f(a)
) — int&
.
Стоит отметить, что static_cast<T&&>(a)
в данном контексте корректно работает и с const T&
. Например, когда мы вызываем f(b)
(где b
имеет тип const int
), T
будет выводиться как const int
, и static_cast<T&&>(a)
приведет к const int&
, что затем правильно направляется в функцию g
, которая обрабатывает этот конкретный случай.
Если бы функция f
была просто определена как void f(int&& a)
, это бы не сработало для const int&
, поскольку такой вариант не смог бы использовать универсальную ссылку, что ограничивало бы ее применение только для rvalue.
Стоит подчеркнуть, что использование std::forward
должно сочетаться с внешним методом, который поддерживает перемещения/универсальные ссылки. Использовать std::forward
самостоятельно, как в приведенных примерах, допустимо, но это не приносит никакой пользы, кроме как создавать путаницу. Возможно, комитет по стандартам должен рассмотреть возможность ограничения такой гибкости — иначе, почему бы просто не использовать static_cast
вместо этого?
std::forward<int>(1);
std::forward<std::string>("Hello");
На мой взгляд, std::move
и std::forward
являются паттернами проектирования, которые естественным образом возникли после введения типа ссылки на r-значения. Мы не должны называть метод, предполагая, что он будет правильно использован, если неправильное использование не запрещено.
В вашем вопросе вы затрагиваете тему передачи rvalue и lvalue ссылок при использовании универсальных ссылок в C++. Давайте рассмотрим подробнее, как это работает на примерах, которые вы привели.
Когда вы пишете:
auto&& x = 2; // x имеет тип int&&
x
становится обобщенной ссылкой (universal reference), и тип переменной будет int&&
, так как вы инициализируете ее временным значением.
Следующим шагом:
auto&& y = x; // Но y это int&
Здесь y
принимает тип int&
, потому что x
- это lvalue-ссылка (ссылка на lvalue).
Теперь обратите внимание на:
auto&& z = std::forward<decltype(x)>(x); // z это int&&
С помощью std::forward
, мы сохранили тип z
таким же, как у x
- int&&
. Это важно, когда вы хотите сохранить семантику переменной (в данном случае rvalue-ссылки).
Если мы говорим о lvalue-ссылках, то можно посмотреть на следующий пример:
int i;
auto&& x = i; // x это int&
auto&& y = x; // y это int&
auto&& z = std::forward<decltype(x)>(x); // z это int&
В этом случае все ссылки (x
, y
, z
) будут иметь тип int&
, как мы и ожидали, поскольку i
- это lvalue.
Теперь, если у вас есть функция с перегрузками, которая принимает int&
и int&&
, и вы хотите передать переменные, подобные z
, а не y
, использование std::forward
является правильным способом, чтобы обеспечить корректное разрешение перегрузок.
Чтобы проверить типы переменных в вашем примере, вы можете использовать std::is_same_v
следующим образом:
std::cout << std::is_same_v<int&, decltype(z)>; // Это вернет true
std::cout << std::is_same_v<int&&, decltype(z)>; // Это вернет false
Если у вас остались вопросы или нужны дополнительные примеры, не стесняйтесь спрашивать!
Что означает T&& (двойной амперсанд) в C++11?
Что такое лямбда-выражение и когда его следует использовать?
Почему следует использовать указатель вместо самого объекта?
Что такое std::move() и когда его следует использовать?
Возможно ли вывести тип переменной в стандартном C++?