[ES] Sobre "perfect forwarding" en C++

Un amigo tenía algunas dudas sobre perfect forwarding en C++, y terminé escribiendo esta explicación.

value categories

Antes de hablar sobre los detalles de la deduccion de tipos en C++ es fundamental mencionar las value categories.

En C++ hay dos tipos de referencias. Referencias a lvalues y referencias a rvalues. Llamemoslas L-refs y R-refs, respectivamente.

  • Normalmente, las L-refs apuntan a objetos que tienen una ubicación fija en memoria, mientras que las R-refs apuntan a objetos temporales.
  • Normalmente, una L-ref se denota T& y una R-ref se denota T&&, donde T es un tipo concreto.
  • Por convención, si recibís una R-ref, tenes derecho a romper el objeto al que apunta. En caso contrario, no.
  • std::move toma una L-ref y la castea a R-ref.

template argument deduction

Digamos que tenemos una funcion monki, que tiene un template parameter T. Y digamos que T aparece dentro del tipo de uno de los parametros de monki. Por ejemplo:

template<typename T>
void monki(vector<T> const& vals) { /* ... */ }

Ahora, podemos llamar a monki haciendo algo como monki<int>(mi_vector_de_ints) (suponiendo que existe esa variable y tiene el tipo correcto). Entonces vals tiene tipo vector<int> const& (L-ref a vector<int> const)

Alternativamente, podemos llamar a monki sin especificar el template argument, de esta forma monki(mi_vector_de_ints). En estos casos, el compilador hace "template argument deduction" y logra deducir que T=int. Nuevamente, vals tiene tipo vector<int> const&.

Esto es genial y anda para muchos casos. Incluso podemos hacer esto:

template<typename T>
void pipo(T const& val) { /* ... */ }

Acá, si hacemos pipo(mi_vector_de_ints), el compilador deduce T=vector<int>. Y el val resulta tener tipo vector<int> const& (L-ref a vector<int> const). Así, nuestra función puede tomar cualquier valor, siempre que no lo mute.

tomando ownership

Ahora digamos que nuestra funcion quiere tener "ownership" del valor. Tal como la escribimos, no le queda otra que hacer una copia (podria tomar una L-ref a no-const y robar el objeto, pero eso rompe las convenciones).

template<typename T>
void pipo(T const& val) {
  T owned = val; // copiado
  /* ... */
}

Pero que pasa? Si el caller no va a usar mas ese valor, podriamos tomar una R-ref y moverlo.

template<typename T>
void pipo(T& [[R-ref]]  val) { // sintaxis falsa
  T owned = move(val); // movido
  /* ... */
}

Gracias a que C++ tiene overloading, con esas dos definiciones:

  • si escribimos pipo(mi_vector_de_int), como mi_vector_de_int es una L-ref, el compilador elige la primera, deduciendo T=vector<int> y val tiene tipo vector<int> const&.
  • si escribimos pipo(std::move(mi_vector_de_int)), como el argumento es una R-ref, el compilador (hipoteticamente) elige la segunda, tambien deduciendo T=vector<int> y val tiene tipo vector<int>&&.

El problema con esto es que tenemos que escribir nuestra función dos veces: una con L-refs y una para R-refs, lo cual es molesto y propenso a errores. Para resolverlo, los escritores del estándar inventaron las "referencias universales", que nos permiten tener polimorfismo sobre L-ref vs R-ref.

referencias universales

template<typename T>
void pipo(T [[universal-ref]]  val) { // sintaxis falsa
  /* ... */
}

Cuando le ponemos una referencia universal a un argumento, permitimos que el compilador deduzca un tipo de referencia para T. Osea, a diferencia de antes, que T siempre era algo como int o vector<int> y el tipo de referencia venía de la signatura de la función, ahora T mismo puede ser int& o vector<int>&& y depende puramente del argumento que pasamos.

Por ejemplo:

  • si llamamos pipo(mi_vector_de_int), el compilador hace template argument deduction y concluye que T = vector<int>& (osea una L-ref)
  • si llamamos pipo(std::move(mi_vector_de_int)), el compilador deduce T = vector<int>&& (que es una R-ref)
  • si llamamos pipo(10), el compilador deduce T = int&& (una R-ref)

decay

Lo que vimos hasta ahora anda perfecto siempre que pipo no quiera pasar val a otra función. Pero hay un problema

template<typename T>
void pipo(T [[universal-ref]]  val) { // sintaxis falsa
    gato(val);
}

template<typename U>
void gato(U [[universal-ref]]  val) { // sintaxis falsa
  /* ... */
}

En este ejemplo, incluso si pasamos una R-ref a pipo -- por ejemplo haciendo pipo(10), que implica T = int&&, que es una R-ref -- cuando pipo le pasa el valor a gato, lo pasa como L-ref, por lo que se deduce U = int&. Este efecto se llama decay, y es bastante molesto cuando escribimos código con templates. Por qué existe? Nadie sabe.

Esto es un efecto general en C++. Osea, incluso si tenemos la declaracion T val con T=int&&, cada aparicion de la expresion val tiene tipo int&.

perfect forwarding

Para resolver el tema del decay, se creó std::forward, que se asegura que se pase el argumento como el tipo que uno pone en el template argument.

std::forward<T>(val) siempre tiene tipo T. En particular, si T=int&&, entonces std::forward<T>(val) tiene tipo int&&

Esto nos permite mantener la value category a traves de llamadas a funciones. Pero, bueno, está claro que solo sirve cuando un template parameter puede tener distintas value categories, que solo pasa cuando trabajamos con referencias universales.

la sintaxis

Ahora. Los diseñadores de C++ son medio boludos y decidieron que la sintaxis para una referencia universal debería ser la misma que la de los R-refs.

Entonces cuando ponemos T&& val en un parámetro de una función -- siendo T un template parameter de la función y no de un scope externo, como de una struct o clase ni un tipo concreto -- no estamos señalando que val es una R-ref a un tipo concreto T, sino que le permitimos al compilador deducir que T es un tipo referencia.

conclusion

Si T es un template parameter de una función, T está sujeto a template argument deduction cuando se llama la función. Si el binding mediante el cual se hace la deduccion es una referencia universal (osea tiene &&), entonces se puede deducir que T es un tipo referencia. En estos casos tiene sentido usar std::forward para mantener la value category.

En cambio, si T es un tipo concreto (o un template argument de un scope externo), no va a estar sujeto a deduccion cuando se llama la funcion, y no puede ser una referencia universal. Así T&& va a ser una R-ref al tipo T (que ya está determinado por otro medio). En estos casos no tiene sentido usar std::forward

Comments

Popular posts from this blog

[EN] Writing a Compiler is Surprisingly Easy (part 1)

[EN] Writing a Compiler is Surprisingly Easy (part 2)

[EN] Representing Programs Within Programs is Surprisingly Easy