Semánticas de movimiento en C++¶
A pesar de que C++ moderno puede desarrollar aplicaciones muy rápidas y eficientes, durante muchos años, una de sus debilidades fue la creación de objetos temporales.
El estándar C++98 definió algunas técnicas de optimización del compilador, como Copy Elision y Return Value Optimization, que resolvieron parcialmente este problema, pero el verdadero cambio fue la semántica de movimiento introducida en C++11.
Semánticas de movimiento¶
Para entender las semánticas de movimiento primero echemos un vistazo a la semántica de copia. En general todas las clases de C++ pueden copiarse mediante uno de estos tres métodos:
Copia de constructor
T t1;
T t2(t1);
Copia con operador de asignación
T t1, t2;
t2 = t1;
Movimiento de constructor (C++11)
T t1;
T t2(std::move(t1));
Movimiento con operador de asignación (C++11)
T t1, t2;
t2 = std::move(t1);
En general las semánticas nos permiten tomar un objeto del contexto actual y pasarlo a otro, evitando la copia del original cuando éste no sigue siendo necesario. Si queremos mover objetos, necesitamos utilizar la función std::move
.
Cabe mencionar dos cuestiones relacionadas con los anteriores ejemplos:
- ¿Qué ocurre con la variable
t1
después del movimiento? De acuerdo con el funcionamiento estándar de una variable en C++, después de moverla será válida pero no tendrá un estado específico. Eso significa que solo puede ejecutar operaciones que no necesiten precondiciones (como asignarle unnew object
). - ¿Cómo funciona
std::move
? Pues la verdad es que esta función no mueve nada. Para saber qué sucede realmente debemos aprender qué son losl
(left) yr
(right)values
.
Lvalues y rvalues¶
En C++, a diferencia de C, una variable puede declararse como una referencia. Antes de C++11 una referencia podía apuntar únicamente a un lvalue
(algo cuya dirección se puede tomar).
int counter = 10;
int &counterRef = counter;
Desde C++11 la referencia puede apuntar al lvalue
o al rvalue
. La referencia rvalue
es básicamente una referencia a un objeto temporal (el lado derecho de una expresión de asignación):
int &&counterRef = 10;
Referencia rvalue en las semánticas de movimiento¶
Como he mencionado anteriormente, hay 4 formas de copiar/mover. Sus declaraciones son las siguientes:
Class Punto
{
//Copia de constructor
Punto(const Punto &punto);
// Copia con operador de asignación
Punto &operator=(const Punto &punto);
// Movimiento de constructor
Punto(Punto &&punto);
// Movimiento con operador de asignación
Punto &operator=(Punto &&punto);
}
Como podemos observar, las operaciones de copia toman la referencia lvalue
mientras que las operaciones de movimiento toman la referencia rvalue
, por lo que el objeto se copia o se mueve dependiendo del tipo de referencia. Esto es precisamente lo que hace std::move
, convertir la referencia lvalue
en la referencia rvalue
.
Cuando utilizar semánticas de movimiento¶
Cuando un método toma un rvalue
como parámetro, podemos pasar la referencia rvalue
(la referencia temporal del objeto) y también el objeto temporal en sí: 100
, temp
, Punto()
.
Es una buena práctica crear sobrecargas de los métodos tomando lvalues
y rvalues
, como por ejemplo implementan algunos de los contenedores STL en el método push_back()
:
void push_back(const T &obj);
void push_back(T &&obj);
Nos permite crear copias (si el objeto todavía se necesita en ese contexto) o moverlo (si ya no se necesita):
std::vector<Punto> puntos;
Punto p1, p2;
puntos.push_back(punto1); // lvalue
puntos.push_back(std::move(punto2)); // rvalue
Este es el uso típico de las semánticas de movimiento.
Cuando no utilizar semánticas de movimiento¶
Un error común es utilizar std::move
cuando la variable local es retornada por una función:
std::vector<int> getNumeros()
{
std::vector<int> numeros = {1,2,3};
return std::move(numeros);
}
auto numeros = getNumeros();
En este caso se crean 2 objetos:
- La variable local
numeros
dentro de la funcióngetNumeros
(objeto temporal). - El objeto del lado izquierdo donde se llama
getNumeros
, este objeto el cual se crea mediante el constructorstd::move
.
El problema aquí es que el compilador por defecto utiliza una técnica de optimización llamada RVO (Optimización del Valor de Retorno), con el objetivo de evitar copias en objetos temporales.
Si borramos std::move
del código, sin RVO se crean en su lugar 3 objetos:
std::vector<int> getNumeros()
{
std::vector<int> numeros = {1,2,3};
return numeros;
}
auto numeros = getNumeros();
- La variable local
numeros
dentro de la funcióngetNumeros
(objeto temporal). - El objeto del lado derecho donde se llama
getNumeros
(objeto temporal). - El objeto del lado izquierdo donde se llama
getNumeros
.
Con RVO solo se creará 1 objeto:
std::vector<int> getNumeros()
{
return std::vector<int>{1, 2, 3};
}
auto numeros = getNumeros();
En conclusión¶
Las semánticas de movimiento son una poderosa técnica para ayudarnos a evitar copias innecesarias, pero para sacar el máximo provecho debemos recordar que los compiladores modernos optimizan el código en algunos casos y pueden hacerlo mejor que utilizar std::move
.
Última edición: 09 de Mayo de 2022