Referencias a R-valor (2ª parte)

febrero 18, 2010

En el post anterior describía cómo las referencias a R-valor permiten definir un constructor de movimiento y un operador de movimiento. Una clase que implementa ambos se llama clase movible.

Si una clase es copiable (es decir, implementa el constructor de copia y el operador de asignación), entonces hacerla también movible es una optimización: cuando el origen es un temporal nos ahorramos la copia. Pero si hacemos movible una clase no copiable… eso es algo nuevo, que no puede definirse con la versión anterior del lenguaje. Por ejemplo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class File
{
public:
    explicit File(FILE *file = NULL)
        :m_file(file)
    {
    }
    ~File()
    {
        if (m_file)
            fclose(m_file);
    }
    FILE *Get() const
    {
        return m_file;
    }    
 
    //copia
    File(const File &o) = delete;
    File &operator=(const File &o) = delete;
 
    //movimiento
    File(File &&o)
    {
        m_file = o.m_file;
        o.m_file = NULL;
    }
    File &operator=(File &&o)
    {
        if (m_file)
            fclose(m_file);
        m_file = o.m_file;
        o.m_file = NULL;
        return *this;
    }
 
private:
    FILE *m_file;
};

La sintaxis de las líneas 19 y 20 es nueva en C++0x; lo que hacen es impedir que el compilador genere el constructor de copia y el operador de asignación por defecto. En versiones anteriores del lenguaje se escribiría el prototipo de la función, sin definición, en la zona privada de la clase. En las líneas 23 y 28 se declaran las operaciones de movimiento, por lo tanto esta clase es movible pero no copiable (no puede ser copiable porque si se copiara el destructor llamaría a fclose() varias veces sobre el mismo fichero).
Esto nos permite hacer cosas como las siguientes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
File OpenForWrite(const std::string &name)
{
    FILE *f = fopen(name.c_str(), "w+");
    if (!f)
        throw ...; //error
    return File(f);
}
 
int main()
{
    File fich;
    //...
    fich = OpenForWrite("log.txt"); //bien: se mueve
    //...
    File copia(fich); //error: no es copiable!
}

En la línea 6 se crea un File temporal y se mueve al retorno de la función, que es otro temporal, que en la línea 13 se mueve a la variable local.
En el caso de que se nos olvide asignar el retorno de la función, el temporal se destruirá sin haberse movido y el fichero se cerrará correctamente. Es decir, nunca puede haber más de un objeto File refiriéndose al mismo fichero, y cuando este se destruya el fichero se cierra. ¡Es imposible usarla mal! Bueno… casi imposible:

File uno = OpenForWrite("a");
File dos(uno.Get()); //compila, pero está mal!

Podemos evitar esto fácilmente declarando el constructor que recibe el FILE* privado y la función OpenForWrite() o bien miembro estático de la clase, o bien función amiga. Entonces serían también convenientes varias funciones más, del tipo OperForRead(), OperForAppend(), etc.

Bien pensado, para la mayoría de las clases no copiables hay pocas razones por las que no pueda ser movible. De hecho, casi todas las clases de la librería estándar de C++0x son movibles, aun siendo no copiables, desde std::fstream hasta std::thread.

Ahora bien, ¿qué pasa si queremos forzar el movimiento de un objeto de una variable a otra? Ambos son L-valores, así que la sobrecarga del operador de asignación/movimiento no vale. Muy sencillo, se hace un cast a referencia a R-valor:

File uno, dos;
uno = OpenForWrite("a");
dos = uno; //error: no es copiable!
dos = static_cast<File&&>(uno); //bien, se escoge el movimiento

Como esta operación será probablemente bastante habitual, la librería estándar proporciona una función que hace exactamente eso, pero más fácil de escribir y de leer:

File uno, dos;
uno = OpenForWrite("a");
dos = std::move(uno);

La función std::move() es bastante sencilla:

template <typename T> typename remove_reference<T>::type&& move(T &&t)
{
    return static_cast<typename remove_reference<T>::type&&>(t);
}

El argumento de la función se declarar como referencia a R-valor de tipo T para que sirva tanto con L-valores como con R-valores.
Y toda ese lío con remove_reference es necesario por la forma en la que interaccionan las referencias a R-valor en los templates. A ver si consigo explicarlo, supongamos que estuviera definida así:

template <typename T> T&& move_mal(T &&t)
{
    return static_cast<T&&>(t);
}

Si se usa la función move_mal() con un argumento R-valor, entonces T se deduce como el tipo de la expresión, la función devuelve una referencia a R-valor y todo va bien. Pero si se usa con un argumento L-valor, entonces las reglas de deducción de tipos en templates dicen que T es una referencia a L-valor al tipo del argumento. Así, el tipo de retorno, que es T&&, es una referencia a R-valor de una referencia a L-valor, por lo que se aplica el colapso de referencias y se queda en una referencia a L-valor, que no nos vale. Utilizando el template std::remove_reference quitamos la referencia a L-valor y recuperamos el comportamiento correcto.

El forwarding perfecto

Advertencia: sección técnica a la vista :-P
El forwarding consiste en escribir una función template, f(), que reciba parámetros arbitrarios y llame a otra función, g(), usando esos mismo parámetros. Se llama forwarding perfecto a la definición de f() que cumpla:

  • La llamada a f() con ciertos argumentos es válida si, y solo si, la llamada a g() con los mismos argumentos es válida.
  • Si g() está sobrecargada, la llamada a f() con ciertos argumentos llama a la misma sobrecarga de g() que si se usasen esos argumentos directamente con g().

Con la versión anterior de C++ el forwarding perfecto se consigue con un par de funciones sobrecargadas:

template <typename A> void f(A &a)
{
    g(a);
}
template <typename A> void f(const A &a)
{
    g(a);
}

La primera se usa para L-valores no constantes, y la segunda para L-valores constantes y R-valores. El problema de esta solución es que no es escalable, con un argumento está bien, pero si fueran 8 argumentos, por ejemplo, se necesitarían 256 sobrecargas, con todas las combinaciones posibles de consts, y eso es inviable.

Con las nuevas referencias a R-valor de C++0x, y una pequeña función auxiliar de la librería estándar puede escribirse así:

template <typename A> void f(A &&a)
{
    g(std::forward<A>(a));
}

La función template std::forward() se define así:

template <typename T, typename U> T &&forward(U &&u)
{
    return static_cast<T&&>(u);
}

Básicamente, esta función convierte el argumento a una referencia a R-valor, ¡lo mismo que std::move()! ¿O tal vez no?
La clave de todo está en que no usa remove_reference, que el tipo de retorno y del argumento pueden ser diferentes y que el compilador no puede deducir el tipo T, es decir, hay que indicarlo obligatoriamente en cada uso.
Veamos el ejemplo de antes:

template <typename A> void f(A &&a)
{
    g(std::forward<A>(a));
}

Cuando llamamos a f(), el argumento puede ser básicamente:

  • Un L-valor no constante de tipo T; A se deduce como T&, y la especialización std::forward() devuelve una referencia a R-valor a una referencia a L-valor, que colapsa a una referencia a L-valor. Forwarding correcto.
  • Un L-valor constante de tipo T; A se deduce como const T&, y la especialización std::forward() es análoga al punto anterior. Forwarding correcto.
  • Un R-valor (el const en este contexto se ignora) de tipo T; A se deduce como simplemente T, y la especialización std::forward() devuelve una referencia a R-valor a T. Forwarding correcto.
  • Si a esto le añadimos los templates con un número variable de argumentos (ya los comentaré otro día) hemos conseguido el forwarding perfecto.

    NOTA: en versiones preliminares de C++0x se definía std::forward() de la siguiente manera:

    template <typename T> struct identity
    {
        typedef T type;
    };
    template <typename T> T &&forward(typename identity<T>::type &&t)
    {
        return static_cast<T&&>(t);
    }

    La función del template identity es, exclusivamente, impedir la deducción automática del tipo T, y obligar al usuario a indicarlo a mano.

    Artículos relacionados:

    1. Referencias a R-valor (1ª parte) En un post anterior comentaba que en C++ no se...
    2. Tipos automáticos en C++0x con auto La palabra reservada auto es casi con toda seguridad la...
    3. Deducción de tipos en C++0x con decltype El otro día veíamos la deducción automática de tipos usando...
    4. Referencias a referencias ¿Es posible en C++ declarar una referencia a referencia? Y...
    5. Templates con número variable de argumentos en C++0x Allá por el año 1999 se publicaba el nuevo y...

    One Response to “Referencias a R-valor (2ª parte)”

    1. Me he quedado con ganas de más. Estoy esperando por el siguiente capítulo. ;-)

      JavierRC

    Deja un comentario