Основные цели 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++?