Más información sobre las referencias de Rvalue | audacia

En las últimas dos publicaciones, Master C++ Copy Semantics y Inside Lvalues ​​& Rvalues ​​​​in C++, analizamos algunos conceptos elementales que son importantes para una comprensión sólida de la semántica de Move.

En esta tercera publicación, veremos en detalle las referencias de Rvalue, también conocidas como identificadores para valores temporales. Tal concepto puede parecer relativamente trivial a primera vista, pero de hecho, es el mecanismo que hace posible la semántica de Move y las ideas detrás de ella en primer lugar. Echemos un vistazo a por qué esto es así.

Contenido

¿Qué son las referencias RValue?

En el segundo post ya aprendimos sobre los Rvalues ​​como valores temporales que se usan para inicializar variables, por ejemplo. Los valores R no son accesibles para nosotros a través de su propia dirección, aunque son un objeto en la memoria al igual que los valores L. Aquí, en el ejemplo, la variable k es un valor L y el 5 en el lado derecho del operador de asignación es un valor R.

C++11 introdujo la referencia Rvalue por primera vez, que es una herramienta que nos permite obtener acceso permanente a objetos temporales en la memoria.
Las referencias de Rvalue funcionan en principio de manera similar a las referencias de Lvalue: las declaramos escribiendo el tipo de datos del rvalue seguido de && y un identificador. Luego, como se muestra en el ejemplo, podemos usar el operador de asignación para conectar el valor r 42 a la referencia j:

Una vez que se completa la asignación, tenemos acceso de lectura y escritura a la ubicación de la memoria donde se almacena el número 42. Básicamente, hemos convertido el Rvalue temporal 42 en un Lvalue j permanente que existe mientras la referencia Rvalue sea válida.

Una de las ideas principales detrás de las referencias de Rvalue es hacer que las asignaciones sean más eficientes al guardar las operaciones de copia. Lo mejor es ver cómo funciona una asignación normal sin referencias Rvalue.

En el ejemplo, la suma de dos variables k y l se asigna a una variable m. Todas las variables son valores l, pero la suma k + l es un valor r porque el operador de suma devuelve el resultado solo temporalmente y sin identificador.

La instrucción del ejemplo se puede dividir en cuatro pasos individuales:

  1. En el primer paso, el lvalue m se crea en la memoria.
  2. Se calcula la suma k + l y el resultado también se almacena en la memoria, pero como un valor R.
  3. Este valor temporal luego se copia en la dirección de m. Por un breve momento, el valor existe el doble y, por lo tanto, consume el doble de memoria. 4.
  4. Posteriormente, el valor temporal se libera nuevamente y el sistema puede eliminarlo.

Una asignación de valor simple genera dos objetos en la memoria, una operación de copia y una operación de eliminación. Ahora veamos lo mismo nuevamente con una referencia Rvalue en lugar de un Lvalue.

Una de las grandes ventajas de las referencias Rvalue es que ahorran operaciones de copia a la hora de asignar valores. La idea es utilizar el resultado de, por ejemplo, una operación de suma directamente sin tener que copiar el valor. El ejemplo muestra el principio:

Primero, se crea una referencia Rvalue n en la memoria. El operador de suma luego calcula la suma de los dos valores de l k y l, y luego la dirección del resultado temporal se asigna a n sin copiar el valor real. En comparación con la asignación de valor convencional Rvalue a Lvalue, ahorramos memoria, una operación de copia y una operación de eliminación. En general, la cantidad de ahorro es proporcional al tamaño del tipo de datos.

En el código, las referencias de Rvalue se pueden usar como identificadores regulares y, por lo tanto, Lvalues ​​​​para acceder a los objetos anteriormente temporales en la memoria.

Ya hemos visto, entre otras cosas, en la semántica de copia que las referencias de Lvalue se pueden usar de manera muy efectiva como parámetros de función. Por lo tanto, ahorramos operaciones de copia al pasar argumentos creando un alias local en su lugar. El alias se puede usar para acceder directamente al argumento en el original desde dentro de la función.

Lo mismo funciona con referencias rvalue. Si pasamos un Rvalue como argumento en lugar de un Lvalue, ya no se copiará, pero también se creará un alias. Podemos acceder a la memoria a través del alias y leer o escribir.

En el lado de la llamada, la llamada se vería así, por ejemplo:

Aquí pasamos el Rvalue 42 a la función passValue(). La principal diferencia con las referencias de Lvalue es que en el lado de la llamada de función, es decir, en main(), el Rvalue ya no es necesario. Solo está disponible dentro de la función a la que se la pasamos. Entonces, podemos usar referencias de rvalue para hacer que pasar rvalues ​​​​a funciones sea más eficiente, porque al igual que con las referencias de lvalue, no necesitamos copiar los argumentos.

Entonces, al igual que las referencias Lvalue, las referencias Rvalue nos ofrecen una forma de aumentar la eficiencia. Ahora veamos por qué, además, también forman la base de la semántica Move: la idea básica de la semántica Move es evitar costosos copia profunda operaciones y utilice operaciones de movimiento más baratas en su lugar.

Justo ahora vimos que las referencias Lvalue y las referencias Rvalue son casi lo mismo: nos proporcionan un alias para un valor de memoria y guardan una operación de copia. En la parte práctica veremos en un momento que no podemos asignar un rvalue a una referencia lvalue y un lvalue a una referencia rvalue. Si escribimos una función que debería poder hacer ambas cosas, entonces tenemos que sobrecargarla en consecuencia (es decir, escribir dos variantes de la misma) con una referencia de valor l y una referencia de valor r como parámetros.

El compilador puede notar la diferencia y llama a la variante correcta según el argumento pasado. Esta capacidad de distinguir entre lvalues ​​y rvalues ​​es el principio básico por el cual funciona la semántica de movimiento: tenemos la posibilidad de detectar objetos temporales, es decir, Rvalues, y así tratarlos de manera diferente dentro de una función que Lvalues.

En la parte práctica, ahora veremos algunos ejemplos de referencias de Rvalue antes de implementar las operaciones de movimiento reales en la próxima publicación.

RValue referencias en la práctica.

Hasta ahora hemos aprendido acerca de la referencia Rvalue como contraparte de la referencia Lvalue, es decir, como un identificador de alias para objetos (temporales). Sin embargo, antes de adentrarnos en el significado de la referencia Rvalue para la semántica de movimientos, el punto aquí es conocer las reglas del juego cuando se usa esta construcción en la práctica.

Experimento 1

En el primer experimento, queremos ver la diferencia entre las referencias Lvalue y las referencias Rvalue. Para hacer esto, primero creamos un Lvalue en Listado 16es decir, una variable i de tipo entero (→//1).

Luego creamos una referencia lvalue llamada lv_ref y la vinculamos a i. Es importante tener en cuenta que la referencia debe tener el mismo tipo de datos que la variable. Como se puede ver en la línea comentada, no se pueden asignar valores de r a una referencia de valor de l. El compilador genera el siguiente mensaje de error para este caso: la referencia de lvalue al tipo ‘int’ no se puede vincular a un temporal de tipo ‘int’.

A continuación, queremos crear una referencia rvalue rvárbitro(//2). Para hacer esto, anteponemos el identificador con el doble Kaufmans Y y vinculamos la referencia al valor temporal 10. Debido al vínculo, este valor sigue siendo válido más allá del final de la declaración y es válido hasta el alcance de rvref se deja de nuevo. Por lo tanto, hemos utilizado una referencia rvalue para cambiar la vida útil de un objeto temporal.

Si ahora probamos cambiar el 10 al valor i, obtenemos nuevamente un mensaje de error: la referencia de valor r al tipo ‘int’ no puede enlazarse con el valor l de tipo ‘int’.

Por lo tanto, solo podemos asignar un valor temporal a una referencia de valor variable y ningún valor variable. Sin embargo, si posteriormente asignamos el valor 20 a rvref y luego generar el valor de la referencia rvalue rvreferencia a la consola, entonces salida 2 muestra que el cambio ha afectado directamente al objeto original en la memoria. Entonces podemos usar referencias de rvalue para hacer que los valores temporales estén disponibles permanentemente y cambiarlos.

Experimento 2

En el segundo experimento, queremos ver la capacidad del compilador para distinguir los valores L de los valores R y llamar a la variante de función correspondiente según el tipo de argumento pasado.

void passValue(int &&param)
{
    cout << "Rvaluen";
}

void passValue(int &param)
{
    cout << "Lvaluen";
}

int main()
{
    // 1
    int i{5};
    int &lv_ref = i;
    // int &lv_ref = 5; // Error

    // 2
    int &&rv_ref = 10;
    // int &&rv_ref = i; // Error
    rv_ref = 20;
    cout << "rv_ref = " << rv_ref << endl;

    // 3
    passValue(i);
    passValue(5);

    // 4
    passValue(rv_ref); 

    return 0;
}

Listado 16: Experimentos con referencias rvalue.

Ausgabe 2: 
rv_ref = 20

Ausgabe 3: 
Lvalue
Rvalue

Ausgabe 4: 
Lvalue

Salidas para el Listado 16

Listado 16 por lo tanto, contiene dos variantes de la función passValue() para valores L con & y valores R con &&. Dentro de las funciones, se envía una cadena correspondiente a la consola. En main() ahora llamamos al nombre de la función primero con el lvalue i y luego con un rvalue 5 como argumento (→//3). De salida 3 podemos ver que el compilador puede elegir la función correcta según el argumento. En la próxima publicación, veremos esto nuevamente en detalle en el contexto del constructor de movimiento y el operador de asignación de movimiento.

Finalmente, analicemos un ejemplo más. Ya hemos creado una referencia rvalue con rvref y lo usé para tareas. Ahora surge la pregunta a qué variante de passValue() se llama cuando pasamos rvref, es decir, una referencia Rvalue, como argumento (→//4).

En salida 4 podemos ver que, curiosamente, el compilador interpretó rv_ref no como un valor R, sino como un valor L. Esto muestra una conexión muy importante: las referencias de rvalue son básicamente lvalues: si adjuntamos un identificador a un valor temporal en la memoria y, por lo tanto, lo hacemos disponible en todo el alcance, entonces la referencia de rvalue se comporta de la misma manera que un lvalue. Y es por eso que solo tiene sentido aquí si el compilador llama a la variante Lvalue de la función.

Entonces, las referencias de Rvalue permiten crear un alias para valores temporales. El compilador puede saber cuándo se llama a una función si se pasó un valor l o un valor r. En la próxima publicación veremos cómo esto es posible gracias a la semántica de movimiento.

Desarrolle sus habilidades de C++.

C++ es un lenguaje de programación compilado de alto rendimiento. Los robots, los automóviles y el software integrado dependen de C++ para la velocidad de ejecución. Explore el C++ Developer Nanodegree para ampliar sus habilidades de ingeniería de software en el desarrollo de C++.

COMIENZA A APRENDER

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *