¿Cuáles son los principales propósitos de usar std :: forward y qué problemas resuelve?

438

En perfecta expedición, std::forwardse utiliza para convertir las referencias rvalue nombre t1y t2las referencias rvalue sin nombre. ¿Cuál es el propósito de hacer eso? ¿Cómo afectaría eso a la función llamada innersi dejamos t1& t2como valores?

template <typename T1, typename T2>
void outer(T1&& t1, T2&& t2) 
{
    inner(std::forward<T1>(t1), std::forward<T2>(t2));
}
Steveng
fuente

Respuestas:

789

Tienes que entender el problema de reenvío. Puede leer todo el problema en detalle , pero lo resumiré.

Básicamente, dada la expresión E(a, b, ... , c), queremos que la expresión f(a, b, ... , c)sea ​​equivalente. En C ++ 03, esto es imposible. Hay muchos intentos, pero todos fallan en algún aspecto.


Lo más simple es usar una referencia de valor l:

template <typename A, typename B, typename C>
void f(A& a, B& b, C& c)
{
    E(a, b, c);
}

Pero esto no puede manejar los valores temporales: f(1, 2, 3);ya que no se pueden vincular a una referencia de valor.

El siguiente intento podría ser:

template <typename A, typename B, typename C>
void f(const A& a, const B& b, const C& c)
{
    E(a, b, c);
}

Lo que soluciona el problema anterior, pero voltea los flops. Ahora no permite Etener argumentos no constantes:

int i = 1, j = 2, k = 3;
void E(int&, int&, int&); f(i, j, k); // oops! E cannot modify these

El tercer intento acepta referencias constantes, pero luego const_castestá constlejos:

template <typename A, typename B, typename C>
void f(const A& a, const B& b, const C& c)
{
    E(const_cast<A&>(a), const_cast<B&>(b), const_cast<C&>(c));
}

Esto acepta todos los valores, puede transmitir todos los valores, pero potencialmente conduce a un comportamiento indefinido:

const int i = 1, j = 2, k = 3;
E(int&, int&, int&); f(i, j, k); // ouch! E can modify a const object!

Una solución final maneja todo correctamente ... a costa de ser imposible de mantener. Proporciona sobrecargas de f, con todas las combinaciones de const y no const:

template <typename A, typename B, typename C>
void f(A& a, B& b, C& c);

template <typename A, typename B, typename C>
void f(const A& a, B& b, C& c);

template <typename A, typename B, typename C>
void f(A& a, const B& b, C& c);

template <typename A, typename B, typename C>
void f(A& a, B& b, const C& c);

template <typename A, typename B, typename C>
void f(const A& a, const B& b, C& c);

template <typename A, typename B, typename C>
void f(const A& a, B& b, const C& c);

template <typename A, typename B, typename C>
void f(A& a, const B& b, const C& c);

template <typename A, typename B, typename C>
void f(const A& a, const B& b, const C& c);

N argumentos requieren 2 combinaciones de N , una pesadilla. Nos gustaría hacer esto automáticamente.

(Esto es efectivamente lo que hacemos que el compilador haga por nosotros en C ++ 11).


En C ++ 11, tenemos la oportunidad de arreglar esto. Una solución modifica las reglas de deducción de plantillas en los tipos existentes, pero esto potencialmente rompe una gran cantidad de código. Entonces tenemos que encontrar otra manera.

La solución es utilizar en su lugar las nuevas referencias de valor agregadas ; Podemos introducir nuevas reglas al deducir los tipos de referencia rvalue y crear cualquier resultado deseado. Después de todo, no podemos romper el código ahora.

Si se le da una referencia a una referencia (la referencia de nota es un término abarcativo que significa ambos T&y T&&), usamos la siguiente regla para determinar el tipo resultante:

"[dado] un tipo TR que es una referencia a un tipo T, un intento de crear el tipo" lvalue reference to cv TR "crea el tipo" lvalue reference to T ", mientras que un intento de crear el tipo" rvalue reference to cv TR "crea el tipo TR".

O en forma tabular:

TR   R

T&   &  -> T&  // lvalue reference to cv TR -> lvalue reference to T
T&   && -> T&  // rvalue reference to cv TR -> TR (lvalue reference to T)
T&&  &  -> T&  // lvalue reference to cv TR -> lvalue reference to T
T&&  && -> T&& // rvalue reference to cv TR -> TR (rvalue reference to T)

A continuación, con la deducción de argumento de plantilla: si un argumento es un valor de l A, proporcionamos al argumento de plantilla una referencia de valor de l a A. De lo contrario, deducimos normalmente. Esto proporciona las llamadas referencias universales (el término referencia de reenvío ahora es oficial).

¿Por qué es útil esto? Debido a que combinados mantenemos la capacidad de realizar un seguimiento de la categoría de valor de un tipo: si era un valor de l, tenemos un parámetro de referencia de valor, de lo contrario, tenemos un parámetro de referencia de valor.

En codigo:

template <typename T>
void deduce(T&& x); 

int i;
deduce(i); // deduce<int&>(int& &&) -> deduce<int&>(int&)
deduce(1); // deduce<int>(int&&)

Lo último es "reenviar" la categoría de valor de la variable. Tenga en cuenta que, una vez dentro de la función, el parámetro podría pasarse como un valor l a cualquier cosa:

void foo(int&);

template <typename T>
void deduce(T&& x)
{
    foo(x); // fine, foo can refer to x
}

deduce(1); // okay, foo operates on x which has a value of 1

Eso no es bueno. ¡E necesita obtener el mismo tipo de categoría de valor que tenemos! La solución es esta:

static_cast<T&&>(x);

¿Qué hace esto? Considere que estamos dentro de la deducefunción y se nos ha pasado un valor. Esto significa que Tes a A&, por lo que el tipo de destino para el reparto estático es A& &&, o simplemente A&. Como xya es un A&, no hacemos nada y nos queda una referencia de valor.

Cuando se nos pasa un valor r, Tes A, entonces el tipo de destino para el reparto estático es A&&. La conversión produce una expresión rvalue, que ya no se puede pasar a una referencia lvalue . Hemos mantenido la categoría de valor del parámetro.

Poner estos juntos nos da "reenvío perfecto":

template <typename A>
void f(A&& a)
{
    E(static_cast<A&&>(a)); 
}

Cuando frecibe un valor, Eobtiene un valor. Cuando frecibe un valor r, Eobtiene un valor r. Perfecto.


Y, por supuesto, queremos deshacernos de lo feo. static_cast<T&&>es críptico y extraño de recordar; hagamos una función de utilidad llamada forward, que hace lo mismo:

std::forward<A>(a);
// is the same as
static_cast<A&&>(a);
GManNickG
fuente
1
¿No fsería una función y no una expresión?
Michael Foukarakis
1
Su último intento no es correcto con respecto al enunciado del problema: reenviará los valores constantes como no constantes, por lo tanto no reenviará en absoluto. También tenga en cuenta que en el primer intento, el const int iserá aceptado: Ase deduce a const int. Los fallos son para los literales de valores. También tenga en cuenta que para la llamada a deduced(1), x es int&&, no int(el reenvío perfecto nunca hace una copia, como se haría si xfuera un parámetro de valor). Simplemente lo Tes int. La razón que se xevalúa como lvalue en el reenviador es porque las referencias rvalue denominadas se convierten en expresiones lvalue.
Johannes Schaub - litb
55
¿Hay alguna diferencia en el uso forwardo moveaquí? ¿O es solo una diferencia semántica?
0x499602D2
28
@David: std::movedebe llamarse sin argumentos de plantilla explícitos y siempre da como resultado un valor r, mientras que std::forwardpuede terminar como cualquiera de los dos. Úselo std::movecuando sepa que ya no necesita el valor y desea moverlo a otro lugar, std::forwardhágalo de acuerdo con los valores pasados ​​a su plantilla de función.
GManNickG
55
Gracias por comenzar con ejemplos concretos primero y motivar el problema; ¡muy útil!
ShreevatsaR
61

Creo que tener un código conceptual que implemente std :: forward puede agregarse a la discusión. Esta es una diapositiva de la charla de Scott Meyers Un muestreador eficaz de C ++ 11/14

Código conceptual que implementa std :: forward

La función moveen el código es std::move. Hay una implementación (funcional) para eso anteriormente en esa charla. Encontré la implementación real de std :: forward en libstdc ++ , en el archivo move.h, pero no es nada instructivo.

Desde la perspectiva de un usuario, su significado es que std::forwardes una conversión condicional a un valor. Puede ser útil si estoy escribiendo una función que espera un valor o un valor r en un parámetro y quiere pasarlo a otra función como un valor r solo si se pasó como un valor r. Si no ajustara el parámetro en std :: forward, siempre se pasaría como una referencia normal.

#include <iostream>
#include <string>
#include <utility>

void overloaded_function(std::string& param) {
  std::cout << "std::string& version" << std::endl;
}
void overloaded_function(std::string&& param) {
  std::cout << "std::string&& version" << std::endl;
}

template<typename T>
void pass_through(T&& param) {
  overloaded_function(std::forward<T>(param));
}

int main() {
  std::string pes;
  pass_through(pes);
  pass_through(std::move(pes));
}

Efectivamente, imprime

std::string& version
std::string&& version

El código se basa en un ejemplo de la charla mencionada anteriormente. Diapositiva 10, aproximadamente a las 15:00 desde el comienzo.

user7610
fuente
2
Su segundo enlace ha terminado apuntando a un lugar completamente diferente.
Pharap
34

En el reenvío perfecto, std :: forward se usa para convertir la referencia de valor de nombre t1 y t2 en una referencia de valor de nombre sin nombre. ¿Cuál es el propósito de hacer eso? ¿Cómo afectaría eso a la función llamada internal si dejamos t1 y t2 como lvalue?

template <typename T1, typename T2> void outer(T1&& t1, T2&& t2) 
{
    inner(std::forward<T1>(t1), std::forward<T2>(t2));
}

Si usa una referencia rvalue con nombre en una expresión, en realidad es un lvalue (porque se refiere al objeto por su nombre). Considere el siguiente ejemplo:

void inner(int &,  int &);  // #1
void inner(int &&, int &&); // #2

Ahora, si llamamos outerasí

outer(17,29);

nos gustaría que 17 y 29 se envíen al # 2 porque 17 y 29 son literales enteros y, como tales, valores. Pero dado que t1y t2en la expresión inner(t1,t2);son valores, estarías invocando el n. ° 1 en lugar del n. ° 2. Es por eso que necesitamos convertir las referencias de nuevo en referencias sin nombre con std::forward. Entonces, t1in outersiempre es una expresión de valor mientras que forward<T1>(t1)puede ser una expresión de valor dependiendo de T1. Esta última es solo una expresión lvalue si T1es una referencia lvalue. Y T1solo se deduce que es una referencia de valor en caso de que el primer argumento para exterior fuera una expresión de valor.

sellibitze
fuente
Esta es una especie de explicación diluida, pero una explicación muy bien hecha y funcional. La gente debería leer esta respuesta primero y luego profundizar si lo desea
NicoBerrogorry
@sellibitze Una pregunta más, qué enunciado es correcto al deducir int a; f (a): "dado que a es un valor l, entonces int (T &&) equivale a int (int & &&)" o "para hacer que T && sea igual a int &, entonces T debería ser int & "? Prefiero al último.
John
11

¿Cómo afectaría eso a la función llamada internal si dejamos t1 y t2 como lvalue?

Si, después de crear una instancia, T1es de tipo chary T2es de una clase, desea pasar t1por copia y t2por constreferencia. Bueno, a menos que los inner()tome por no constreferencia, es decir, en cuyo caso también desea hacerlo.

Intente escribir un conjunto de outer()funciones que implementen esto sin referencias de valor, deduciendo la forma correcta de pasar los argumentos del inner()tipo. Creo que necesitará algo 2 ^ 2 de ellos, bastante material de plantilla meta para deducir los argumentos, y mucho tiempo para hacerlo bien en todos los casos.

Y luego alguien viene junto con un inner()que toma argumentos por puntero. Creo que ahora hace 3 ^ 2. (O 4 ^ 2. Demonios, no me molesto en tratar de pensar si el constpuntero marcaría la diferencia).

Y luego imagina que quieres hacer esto para cinco parámetros. O siete.

Ahora sabes por qué algunas mentes brillantes idearon el "reenvío perfecto": hace que el compilador haga todo esto por ti.

sbi
fuente
5

Un punto que no se ha aclarado es que también se static_cast<T&&>maneja const T&correctamente.
Programa:

#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);
}

Produce:

f(1)
int&&
f(a)
int&
f(const b)
const int&
f(a * b)
int&&

Tenga en cuenta que 'f' tiene que ser una función de plantilla. Si solo se define como 'void f (int && a)', esto no funciona.

lalawawa
fuente
buen punto, entonces T&& en el reparto estático también sigue las reglas de colapso de referencia, ¿verdad?
barney
3

Puede valer la pena enfatizar que el reenvío debe usarse en conjunto con un método externo con reenvío / referencia universal. El uso de reenviar por sí mismo como las siguientes declaraciones está permitido, pero no sirve de nada más que causar confusión. El comité estándar puede desear deshabilitar dicha flexibilidad, de lo contrario, ¿por qué no usamos static_cast en su lugar?

     std::forward<int>(1);
     std::forward<std::string>("Hello");

En mi opinión, mover y avanzar son patrones de diseño que son resultados naturales después de que se introduce el tipo de referencia de valor r. No debemos nombrar un método asumiendo que se usa correctamente a menos que se prohíba el uso incorrecto.

colin
fuente
No creo que el comité de C ++ sienta que es responsabilidad de ellos usar las expresiones idiomáticas "correctamente", ni siquiera definir qué es el uso "correcto" (aunque ciertamente pueden dar pautas). Con ese fin, mientras que los maestros, jefes y amigos de una persona pueden tener el deber de dirigirlos de una forma u otra, creo que el comité C ++ (y, por lo tanto, el estándar) no tiene ese deber.
SirGuy
Sí, acabo de leer N2951 y estoy de acuerdo en que el comité estándar no tiene la obligación de agregar limitaciones innecesarias con respecto al uso de una función. Pero los nombres de estas dos plantillas de funciones (mover y avanzar) son de hecho un poco confusos al ver solo sus definiciones en el archivo de la biblioteca o en la documentación estándar (23.2.5 Ayudantes de avance / movimiento). Los ejemplos en el estándar definitivamente ayudan a entender el concepto, pero podría ser útil agregar más comentarios para hacer las cosas un poco más claras.
colin