C++ siempre ha producido programas rápidos. Desafortunadamente, hasta C++11, ha habido una verruga obstinada que ralentiza muchos programas C++: la creación de objetos temporales. A veces estos objetos temporales pueden ser optimizados por el compilador (la optimización del valor de retorno, por ejemplo). Pero no siempre es así, y puede dar lugar a costosas copias de objetos. ¿Qué quiero decir?

Digamos que tienes el siguiente código:

#include <iostream>using namespace std;vector<int> doubleValues (const vector<int>& v){ vector<int> new_values; new_values.reserve(v.size()); for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr ) { new_values.push_back( 2 * *itr ); } return new_values;}int main(){ vector<int> v; for ( int i = 0; i < 100; i++ ) { v.push_back( i ); } v = doubleValues( v );}

Si has hecho un montón de trabajo de alto rendimiento en C++, lo siento por el dolor que trajo. Si no lo has hecho… bueno, vamos a ver por qué este código es un código C++03 terrible. (El resto de este tutorial será sobre por qué es un buen código C++11.) El problema está en las copias. Cuando se llama a doubleValues, construye un vector, new_values, y lo llena. Esto por sí solo podría no ser el rendimiento ideal, pero si queremos mantener nuestro vector original sin manchar, necesitamos una segunda copia. Pero, ¿qué ocurre cuando llegamos a la sentencia return?

¡Todo el contenido de nuevos_valores debe ser copiado! En principio, podría haber hasta dos copias aquí: una en un objeto temporal para ser devuelto, y una segunda cuando el operador de asignación de vectores se ejecuta en la línea v = doubleValues( v );. La primera copia puede ser optimizada por el compilador de forma automática, pero no se puede evitar que la asignación a v tenga que copiar todos los valores de nuevo, lo que requiere una nueva asignación de memoria y otra iteración sobre todo el vector.

Este ejemplo puede ser un poco artificioso – y, por supuesto, usted puede encontrar maneras de evitar este tipo de problema – por ejemplo, almacenando y devolviendo el vector por puntero, o pasando un vector para ser llenado. La cuestión es que ninguno de estos estilos de programación es particularmente natural. Además, un enfoque que requiera devolver un puntero ha introducido al menos una asignación de memoria, y uno de los objetivos de diseño de C++ es evitar las asignaciones de memoria.

La peor parte de toda esta historia es que el objeto devuelto por doubleValues es un valor temporal que ya no se necesita. Cuando se tiene la línea v = doubleValues( v ), ¡el resultado de doubleValues( v ) simplemente se va a tirar una vez que se copie! En teoría, debería ser posible omitir la copia completa y simplemente hurtar el puntero dentro del vector temporal y mantenerlo en v. En efecto, ¿por qué no podemos mover el objeto? En C++03, la respuesta es que no había forma de saber si un objeto era temporal o no, había que ejecutar el mismo código en el operador de asignación o en el constructor de copia, sin importar de dónde viniera el valor, así que no era posible el hurto. En C++11, la respuesta es… ¡se puede!

Para eso están las referencias rvalue y la semántica de movimiento. La semántica de movimiento te permite evitar copias innecesarias cuando trabajas con objetos temporales que están a punto de evaporarse, y cuyos recursos pueden ser tomados con seguridad de ese objeto temporal y utilizados por otro.

La semántica de movimiento se basa en una nueva característica de C++11, llamada referencias rvalue, que querrás entender para apreciar realmente lo que está pasando. Así que primero vamos a hablar de lo que es un rvalue, y luego de lo que es una referencia rvalue.Finalmente, volveremos a la semántica de move y cómo se puede implementar con referencias rvalue.

Rvalues y lvalues – ¿rivales acérrimos, o los mejores amigos?

En C++, hay rvalues y lvalues. Un lvalue es una expresión cuya dirección puede ser tomada, un valor localizador–esencialmente, un lvalue proporciona una pieza (semi)permanente de memoria. Se pueden hacer asignaciones a lvalues. Por ejemplo:

int a;a = 1; // here, a is an lvalue

También puede tener lvalues que no sean variables:

int x;int& getRef () { return x;}getRef() = 4;

Aquí, getRef devuelve una referencia a una variable global, por lo que está devolviendo un valor que se almacena en una ubicación permanente. (Podría escribir literalmente & getRef() si quisiera, y le daría la dirección de x.)

Los valores r son… bueno, los valores r no son valores l. Una expresión es un rvalue si resulta en un objeto temporal. Por ejemplo:

int x;int getVal (){ return x;}getVal();

Aquí, getVal() es un rvalue–el valor devuelto no es una referencia a x, es sólo un valor temporal. Esto se pone un poco más interesante si usamos objetos reales en lugar de números:

string getName (){ return "Alex";}getName();

Aquí, getName devuelve una cadena que se construye dentro de la función. Puedes asignar el resultado de getName a una variable:

string name = getName();

Pero estás asignando desde un objeto temporal, no desde algún valor que tenga una ubicación fija. getName() es un rvalue.

Detectando objetos temporales con referencias rvalue

Lo importante es que los rvalues se refieren a objetos temporales–como el valor devuelto por doubleValues. ¿No sería estupendo si pudiéramos saber, sin lugar a dudas, que un valor devuelto por una expresión es temporal, y escribir de alguna manera un código sobrecargado para que se comporte de forma diferente con los objetos temporales? Pues sí, sí que lo sería. Y para eso están las referencias rvalue. Una referencia rvalue es una referencia que se vinculará sólo a un objeto temporal. ¿Qué quiero decir?

Antes de C++11, si tenías un objeto temporal, podías usar una referencia «regular» o «lvalue» para enlazarlo, pero sólo si era const:

const string& name = getName(); // okstring& name = getName(); // NOT ok

La intuición aquí es que no puedes usar una referencia «mutable» porque, si lo hicieras, podrías modificar algún objeto que está a punto de desaparecer, y eso sería peligroso. Fíjate, por cierto, que mantener una referencia constante a un objeto temporal asegura que el objeto temporal no se destruya inmediatamente. Esta es una buena garantía de C++, pero sigue siendo un objeto temporal, por lo que no quieres modificarlo.

En C++11, sin embargo, hay un nuevo tipo de referencia, una «referencia rvalue», que te permitirá enlazar una referencia mutable a un rvalue, pero no a un lvalue. En otras palabras, las referencias rvalue son perfectas para detectar si un valor es un objeto temporal o no. Las referencias rvalue utilizan la sintaxis && en lugar de sólo &, y pueden ser const y non-const, al igual que las referencias lvalue, aunque rara vez verás una referencia rvalue const (como veremos, las referencias mutables son un poco el punto):

const string&& name = getName(); // okstring&& name = getName(); // also ok - praise be!

Hasta aquí todo bien, pero ¿cómo ayuda? Lo más importante de las referencias lvalue frente a las referencias rvalue es lo que ocurre cuando se escriben funciones que toman referencias lvalue o rvalue como argumentos. Digamos que tenemos dos funciones:

printReference (const String& str){ cout << str;}printReference (String&& str){ cout << str;}

Ahora el comportamiento se vuelve interesante–la función printReference que toma una referencia lvalue aceptará cualquier argumento que se le dé, ya sea un lvalue o un rvalue, e independientemente de si el lvalue o el rvalue es mutable o no. Sin embargo, en presencia de la segunda sobrecarga, printReference que toma una referencia rvalue, recibirá todos los valores excepto las referencias mutables. En otras palabras, si se escribe:

string me( "alex" );printReference( me ); // calls the first printReference function, taking an lvalue referenceprintReference( getName() ); // calls the second printReference function, taking a mutable rvalue reference

Ahora tenemos una forma de determinar si una variable de referencia se refiere a un objeto temporal o a un objeto permanente. La versión de referencia rvalue del método es como la entrada secreta de la puerta trasera del club a la que sólo puedes entrar si eres un objeto temporal (club aburrido, supongo). Ahora que tenemos nuestro método para determinar si un objeto es temporal o permanente, ¿cómo podemos usarlo?

Constructor de movimiento y operador de asignación de movimiento

El patrón más común que verás al trabajar con referencias rvalue es crear un constructor de movimiento y un operador de asignación de movimiento (que sigue los mismos principios). Un constructor de movimiento, como un constructor de copia, toma una instancia de un objeto como su argumento y crea una nueva instancia basada en el objeto original. Sin embargo, el constructor move puede evitar la reasignación de memoria porque sabemos que se le ha proporcionado un objeto temporal, así que en lugar de copiar los campos del objeto, los moveremos.

¿Qué significa mover un campo del objeto? Si el campo es un tipo primitivo, como int, simplemente lo copiamos. La cosa se pone más interesante si el campo es un puntero: aquí, en lugar de asignar e inicializar nueva memoria, podemos simplemente robar el puntero y anular el puntero en el objeto temporal. Sabemos que el objeto temporal ya no se necesitará, así que podemos quitarle el puntero. ¡

Imagina que tenemos una simple clase ArrayWrapper, como esta:

class ArrayWrapper{ public: ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; } private: int *_p_vals; int _size;};

Nota que el constructor de la copia tiene que asignar memoria y copiar cada valor del array, uno a la vez! Eso es mucho trabajo para una copia. Añadamos un constructor de movimiento y ganemos en eficiencia.

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _size( 64 ) {} ArrayWrapper (int n) : _p_vals( new int ) , _size( n ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _size( other._size ) { other._p_vals = NULL; other._size = 0; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _size( other._size ) { for ( int i = 0; i < _size; ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; int _size;};

¡Vaya, el constructor de movimiento es más simple que el constructor de copia! Las principales cosas que hay que notar son:

  1. El parámetro es una referencia rvalue no-const
  2. otro._p_vals se establece a NULL

La segunda observación explica la primera–no podríamos establecer otro._p_vals aNULL si hubiéramos tomado una referencia rvalue const. Pero, ¿por qué tenemos que poner other._p_vals = NULL? La razón es el destructor–cuando el objeto temporal sale del ámbito, al igual que todos los demás objetos de C++, se ejecuta su destructor. Los mismos _p_vals que acabamos de copiar. Si no ponemos otros._p_vals a NULL, el movimiento no sería realmente un movimiento–sólo sería una copia que introduce un fallo más tarde, cuando empecemos a usar la memoria liberada. Este es el objetivo de un constructor de movimiento: ¡evitar una copia cambiando el objeto temporal original!

De nuevo, las reglas de sobrecarga funcionan de manera que el constructor de movimiento es llamado sólo para un objeto temporal–y sólo un objeto temporal que puede ser modificado. Esto significa que si tienes una función que devuelve un objeto constante, hará que se ejecute el constructor copy en lugar del constructor move–no escribas código como este:

const ArrayWrapper getArrayWrapper (); // makes the move constructor useless, the temporary is const!

Todavía hay una situación más que no hemos discutido cómo manejar en un constructor move–cuando tenemos un campo que es un objeto. Por ejemplo, imagina que en lugar de tener un campo de tamaño, tuviéramos un campo de metadatos con este aspecto:

class MetaData{public: MetaData (int size, const std::string& name) : _name( name ) , _size( size ) {} // copy constructor MetaData (const MetaData& other) : _name( other._name ) , _size( other._size ) {} // move constructor MetaData (MetaData&& other) : _name( other._name ) , _size( other._size ) {} std::string getName () const { return _name; } int getSize () const { return _size; } private: std::string _name; int _size;};

Ahora nuestro array puede tener un nombre y un tamaño, por lo que tendríamos que cambiar la definición de ArrayWrapper así:

class ArrayWrapper{public: // default constructor produces a moderately sized array ArrayWrapper () : _p_vals( new int ) , _metadata( 64, "ArrayWrapper" ) {} ArrayWrapper (int n) : _p_vals( new int ) , _metadata( n, "ArrayWrapper" ) {} // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { other._p_vals = NULL; } // copy constructor ArrayWrapper (const ArrayWrapper& other) : _p_vals( new int ) , _metadata( other._metadata ) { for ( int i = 0; i < _metadata.getSize(); ++i ) { _p_vals = other._p_vals; } } ~ArrayWrapper () { delete _p_vals; }private: int *_p_vals; MetaData _metadata;};

¿Funciona esto? Parece muy natural, ¿no?, llamar al constructor de MetaDatamove desde el constructor de move de ArrayWrapper? El problema es que esto no funciona. La razón es sencilla: el valor de other en el constructor de move es una referencia rvalue. Pero una referencia rvalue no es, de hecho, un rvalue. Es un lvalue, y por eso se llama al constructor copy, no al constructor move. Esto es raro. Lo sé… es confuso. Esta es la manera de pensar en ello. Un rvalue es una expresión que crea un objeto que está a punto de evaporarse en el aire. Está en su última etapa en la vida – o a punto de cumplir su propósito de vida. De repente, pasamos el temporal a un constructor de movimiento, y adquiere una nueva vida en el nuevo ámbito. En el contexto en el que se evaluó la expresión rvalue, el objeto temporal está realmente acabado. Pero en nuestro constructor, el objeto tiene un nombre; estará vivo durante toda la duración de nuestra función.En otras palabras, podríamos usar la variable other más de una vez en la función, y el objeto temporal tiene una ubicación definida que realmente persiste para toda la función. Es un lvalue en el verdadero sentido del término valor localizador, podemos localizar el objeto en una dirección particular que es estable durante toda la duración de la llamada a la función. De hecho, podríamos querer utilizarlo más adelante en la función. Si se llamara a un constructor move cada vez que mantuviéramos un objeto en una referencia rvalue, podríamos utilizar un objeto movido, ¡por accidente!

 // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( other._metadata ) { // if _metadata( other._metadata ) calls the move constructor, using // other._metadata here would be extremely dangerous! other._p_vals = NULL; }

Por último: tanto las referencias lvalue como rvalue son expresiones lvalue. La diferencia es que una referencia lvalue debe ser const para mantener una referencia a unrvalue, mientras que una referencia rvalue siempre puede mantener una referencia a un rvalue.Es como la diferencia entre un puntero, y lo que se señala. La cosa apuntada viene de un rvalue, pero cuando usamos la referencia rvalue en sí misma, resulta en un lvalue.

std::move

¿Entonces cuál es el truco para manejar este caso? Necesitamos usar std::move, de<utilidad>–std::move es una forma de decir, «ok, honestamente sé que tengo un lvalue, pero quiero que sea un rvalue». std::move no mueve nada por sí mismo; sólo convierte un lvalue en un rvalue, para que puedas invocar el constructor move. Nuestro código debería ser así:

#include <utility> // for std::move // move constructor ArrayWrapper (ArrayWrapper&& other) : _p_vals( other._p_vals ) , _metadata( std::move( other._metadata ) ) { other._p_vals = NULL; }

Y, por supuesto, deberíamos volver a MetaData y arreglar su propio constructor move para que utilice std::move en la cadena que contiene:

 MetaData (MetaData&& other) : _name( std::move( other._name ) ) // oh, blissful efficiency : _size( other._size ) {}

Operador de asignación move

Así como tenemos un constructor move, también deberíamos tener un operador de asignación move. Puedes escribir uno fácilmente usando las mismas técnicas que para crear un constructor de movimiento.

Constructores de movimiento y constructores generados implícitamente

Como sabes, en C++ cuando declaras cualquier constructor, el compilador ya no generará el constructor por defecto para ti. Lo mismo ocurre aquí: añadir un constructor de movimiento a una clase requerirá que declare y defina su propio constructor por defecto. Por otro lado, declarar un constructor de movimiento no impide que el compilador proporcione un constructor de copia generado implícitamente, y declarar un operador de asignación de movimiento no inhibe la creación de un operador de asignación estándar.

¿Cómo funciona std::move

Tal vez se pregunte, cómo se escribe una función como std::move? ¿Cómo se consigue esta propiedad mágica de transformar un lvalue en unrvalue de referencia? La respuesta, como puede adivinar, es el encasillamiento. La declaración real de std::move es algo más complicada, pero en el fondo es sólo un static_cast a una referencia rvalue. Esto significa, en realidad, que no es necesario usar move–pero debería, ya que es mucho más claro lo que quiere decir. El hecho de que se requiera un cast es, por cierto, algo muy bueno. Significa que no puede convertir accidentalmente un lvalue en un rvalue, lo que sería peligroso ya que podría permitir que se produjera un movimiento accidental. Debe usar explícitamente std::move (o un cast) para convertir un lvalue en una referencia rvalue, y una referencia rvalue nunca se unirá a un lvalue por sí misma.

Devolviendo una referencia rvalue explícita desde una función

¿Hay alguna vez en que deba escribir una función que devuelva una referencia rvalue? De todos modos, ¿qué significa devolver una referencia rvalue? ¿No son ya rvalues las funciones que devuelven objetos por valor?

Respondamos primero a la segunda pregunta: devolver una referencia rvalue explícita es diferente a devolver un objeto por valor. Tomemos el siguiente ejemplo sencillo:

int x;int getInt (){ return x;}int && getRvalueInt (){ // notice that it's fine to move a primitive type--remember, std::move is just a cast return std::move( x );}

Claramente en el primer caso, a pesar de que getInt() es un rvalue, se está haciendo una copia de la variable x. Incluso podemos ver esto escribiendo una pequeña función de ayuda:

void printAddress (const int& v) // const ref to allow binding to rvalues{ cout << reinterpret_cast<const void*>( & v ) << endl;}printAddress( getInt() ); printAddress( x );

Cuando ejecute este programa, verá que hay dos valores separados impresos.

Por otro lado,

printAddress( getRvalueInt() ); printAddress( x );

imprime el mismo valor porque estamos devolviendo explícitamente un rvalue aquí.

Así que devolver una referencia rvalue es una cosa diferente a no devolver una referencia rvalue, pero esta diferencia se manifiesta más notablemente si tienes un objeto preexistente que estás devolviendo en lugar de un objeto temporal creado en la función (donde es probable que el compilador elimine la copia por ti).

Ahora a la pregunta de si quieres hacer esto. La respuesta es: probablemente no. En la mayoría de los casos, sólo hace que sea más probable que termines con una referencia colgante (un caso en el que la referencia existe, pero el objeto temporal al que se refiere ha sido destruido). El problema es bastante similar al peligro de devolver una referencia lvalue: el objeto al que se refiere puede dejar de existir. Las referencias rvalue no pueden mantener vivo un objeto por arte de magia.Devolver una referencia rvalue tendría sentido principalmente en casos muy raros en los que se tiene una función miembro y se necesita devolver el resultado de llamar a std::move en un campo de la clase desde esa función–¿y con qué frecuencia se va a hacer eso?

La semántica de move y la biblioteca estándar

Volviendo a nuestro ejemplo original–estábamos usando un vector, y no tenemos control sobre la clase vectorial y si tiene o no un operador de asignación de move constructor. Afortunadamente, el comité de estándares es sabio, y la semántica de move ha sido añadida a la biblioteca estándar. Esto significa que ahora puedes devolver eficientemente vectores, mapas, cadenas y cualquier otro objeto de la biblioteca estándar que quieras, aprovechando al máximo la semántica de move.

Objetos movibles en contenedores STL

De hecho, la biblioteca estándar va un paso más allá. Si habilita la semántica de move en sus propios objetos creando operadores de asignación de move y constructores de move, cuando almacene esos objetos en un contenedor, la STL utilizará automáticamente std::move, aprovechando automáticamente las clases habilitadas para move para eliminar las copias ineficientes.

Articles

Deja una respuesta

Tu dirección de correo electrónico no será publicada.