Referencias de reenvío y auto&&

Artículos de la serie:

Reglas de deducción de tipos


Dada una plantilla de función, una referencia de reenvío es una referencia rvalue a uno de los parámetros de la plantilla. Dicha referencia debe carecer, además, de calificadores const-volatile. A modo de ejemplo, consideremos la plantilla de función f siguiente:

template<typename T> void f(T&& t); // referencia de reenvío en la lista de parámetros de la función

La característica fundamental de una referencia de reenvío como la anterior es la de que preserva la categoría de valor del argumento pasado a la función, así como su carácter constante o no-constante. Con el fin de comprobarlo, y tal y como hicimos en un artículo anterior dedicado a la inferencia automática de tipos, consideremos las definiciones siguientes:

       double a{1.0};    // double no-constante    double& b{a};   // referencia a double no-constante    double const c{2.0};   // double constante    double const& d{a};   // referencia a double constante    double* p{&a};  // puntero a double no-constante    double* const q{&a};  // puntero constante a double no-constante    double const* r{&a};  // puntero a double constante    double constconst s{&a}; // puntero constante a double constante

A continuación, analizaremos los tipos deducidos tanto para el parámetro de la plantilla T como para el parámetro de la función t mediante llamadas de la forma f(E) en casos en los que E sea una expresión lvalue. Aprovecharemos esta ocasión para comparar dichas deducciones con las obtenidas mediante inferencia automática en inicializaciones de la forma auto&& var = E:

 f(a);  // T = decltype(t) = double&   f(b);  // T = decltype(t) = double&   f(c);  // T = decltype(t) = double const&   f(d);  // T = decltype(t) = double const&   f(p);  // T = decltype(t) = double*&   f(*p); // T = decltype(t) = double&   f(q);  // T = decltype(t) = double* const&   f(*q); // T = decltype(t) = double&   f(r);  // T = decltype(t) = double const*&   f(*r); // T = decltype(t) = double const&   f(s);  // T = decltype(t) = double const* const&  f(*s); // T = decltype(t) = double const& 


  auto&& i = a;  // decltype(i) = double&   auto&& j = b;  // double&   auto&& k = c;  // double const&   auto&& l = d;  // double const&   auto&& o = p;  // double*&   auto&& t = *p; // double&   auto&& u = q;  // double* const&   auto&& v = *q; // double&   auto&& w = r;  // double const*&   auto&& x = *r; // double const&   auto&& y = s;  // double const* const&   auto&& z = *s; // double const&

Como podemos observar, T y decltype(t) son ambos deducidos como referencias lvalue de idéntico tipo:
  • En la deducción de Tsi decltype(E) contiene una referencia, ésta es respetada y, si no la posee, es añadida.
  • Siendo T = A&, se tiene que decltype(t) termina coincidiendo con T al aplicarse la siguiente regla de decaimiento de referencias [1]: A& && -> A&.
La tabla anterior demuestra que auto&& funciona de forma análoga, salvo en el caso particular de listas de inicialización entre llaves:

   f({1, 2, 3}); // error: couldn't deduce template parameter 'T'    auto&& m = {123}; // decltype(m) = std::initializer_list<int>&&

La situación es diferente cuando la expresión E es un rvalue. En tal caso rigen las reglas de deducción TAD para plantillas de función con paso por referencia analizadas en un artículo anterior. Así, en la deducción del tipo T [2]:
  1. Cualquier posible referencia en decltype(E) es eliminada.
  2. De existir, los calificadores const-volatile remanentes de primer nivel son respetados.
Por su parte, decltype(t) será una referencia rvalue T&&. A modo de ejemplo:

       f(move(d)); // decltype(d) = double const& // T = double const // decltype(t) = double const&&    auto&& m = move(d); // decltype(m) = double const&&

En general, concluimos que en una referencia de reenvío como la proporcionada por la función f:
  • decltype(t) será siempre una referencia (lvalue o rvalue, según corresponda).
  • De forma notable, el tipo T será una referencia lvalue cuando el argumento de la función sea un lvalue. Esta situación contrasta claramente con el paso por valor o por referencia lvalue tradicionales, para los cuales T nunca sería deducido como una referencia.


Reenvío perfecto (perfect forwarding)


Las referencias de reenvío se emplean siempre que una función no deba realizar un uso directo de un argumento, limitándose a reenviarlo a otra función mediante una acción std::forward [3]. Hablamos en este caso de un reenvío perfecto que preserva la categoría de valor y el posible carácter constante del argumento.

La función std::forward se encuentra disponible en el fichero de cabecera <utility> y consiste en una simple conversión estática de tipos [4]:

   template<typename Tp>    constexpr auto forward(remove_reference_t<Tp>& tp) noexcept -> Tp&&    {       return static_cast<Tp&&>(tp);     }    template<typename Tp>    constexpr auto forward(remove_reference_t<Tp>&& tp) noexcept -> Tp&&    { static_assert(!is_lvalue_reference_v<Tp>, "cannot forward rvalue reference as lvalue reference");  return static_cast<Tp&&>(tp);  }

La primera sobrecarga reenvía lvalues como lvalues o rvalues, dependiendo del tipo Tp. La segunda sobrecarga reenvía rvalues como rvalues e impide el reenvío de rvalues como lvalues. Comparemos en este punto a std::forward con std::move, que realiza una conversión estática a una referencia rvalue de forma incondicional:

   template<typename Tp>    constexpr auto move(Tp&& tp) noexcept -> remove_reference_t<Tp>&&    {       return static_cast<remove_reference_t<Tp>&&>(tp);     }

En la siguiente discusión utilizaremos la primera sobrecarga de std::forward únicamente. Consideremos la clase y función siguientes:

class S {     string str_; public:     S(string const& str)        : str_{str} { cout << "lvalue overload - "; }      S(string&& str)        : str_{move(str)} { cout << "rvalue overload - "; }     void print() const { cout << str_ << '\n'; } }; template<typename T> requires constructible_from<string, T> void g(T&& t) {     S{forward<T>(t)}.print(); }

Como podemos observar, la clase S cuenta con un string privado str_ inicializable de dos formas distintas. El primer constructor recibe una referencia a un string constante, el cual es copiado en str_. El segundo constructor, por su parte, consta de una referencia rvalue a un string, siendo éste movido dentro de str_. La función g hace uso de una referencia de reenvío para, dependiendo de la categoría de valor de su argumento y gracias a std::forward, invocar al constructor adecuado. g llama entonces a la función miembro print a través del objeto temporal de tipo S recién creado. Es importante remarcar que, en el cuerpo de la función gt constituye un lvalue, al ser el nombre del argumento (sea éste lvalue o rvalue), por lo que la sobrecarga invocada de std::forward será siempre la primera proporcionada más arriba.

En efecto, realicemos las siguientes llamadas a función:

auto message = string{"to be, or not to be"};    g(message);  // (A) output: lvalue overload - to be, or not to be    g(move(message));  // (B) output: rvalue overload - to be, or not to be     assert(message.empty()); // (C) ok

En (A), donde el argumento empleado es un lvalue, el tipo T de la plantilla g es deducido como string&. Tras aplicar la regla de decaimiento de referencias [5], las instancias generadas de std::forward y g resultan equivalentes a:

constexpr auto forward(string& t) noexcept -> string& {     return static_cast<string&>(t); } void g(string& t) {     S{forward<string&>(t)}.print(); }

El constructor de S empleado en este caso es el de signatura S(string const&). El objeto temporal de tipo S almacena una copia de message, de forma que el string original no sufre modificación.

Por contra, en (B) el tipo T es deducido como string, resultando las instancias:


constexpr auto forward(string& t) noexcept -> string&& {     return static_cast<string&&>(t); } void g(string&& t) {     S{forward<string>(t)}.print(); }


La propiedad del string message es transferida a la función g, que la reenvía a su vez al objeto temporal de tipo S invocando la sobrecarga del constructor S(string&&). El objeto message original queda vacío tras dicha operación, como se comprueba en (C).

Para finalizar, observemos que en base a lo analizado en este artículo, los dos constructores de la clase S podrían fusionarse en un único constructor template [6]:

   class S {       string str_;    public:       template<typename U>          requires constructible_from<string, U>       S(U&& str)          : str_{forward<U>(str)} { cout << "constructor template - "; }       void print() const { cout << str_ << '\n'; }    };


Referencias bibliográficas
  1. References to references - http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2002/n1377.htm#References%20to%20References
  2. Scott Meyers, Effective Modern C++. O'Reilly Media (2014).
  3. Cpp Core Guidelines - F.19 - https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-forward
  4. Cppreference - std::forward - https://en.cppreference.com/w/cpp/utility/forward
  5. Thomas Becker - Perfect forwarding - http://thbecker.net/articles/rvalue_references/section_08.html
  6. CppCon 2017 - Nicolai Josuttis - 'The Nightmare of Move Semantics for Trivial Classes' - https://www.youtube.com/watch?v=PNRju6_yn3o

No hay comentarios:

Publicar un comentario