La Move Semantics es la capacidad de transferir recursos en lugar de copiarlos cuando un objeto ya no necesita conservarlos.
En la entrada anterior aprendimos a distinguir entre L-values (objetos permanentes) y R-values (temporales desechables).
Ahora vamos a ver por qué esa distinción es tan importante en C++. Vamos a hablar de la semántica de movimiento (move semantics).
Hasta C++11, si querías pasar un objeto complejo (como un std::vector o un std::string) de un lugar a otro, casi siempre implicaba hacer una copia. Y copiar es caro. Requiere reservar nueva memoria y copiar los bits uno a uno.
¿Pero qué pasa si el objeto original es un temporal que se va a destruir inmediatamente después? Copiarlo es un desperdicio absurdo.
La semántica de movimiento permite transferir los recursos de un objeto temporal en lugar de copiarlos.
Copiar frente a mover
Para entenderlo, tenemos una clase que gestiona un puntero a un recurso pesado en memoria.
- Copiar (Copy): Es como si quieres mudarte de casa y, en lugar de llevarte tus muebles, construyes una casa idéntica, compras muebles idénticos, y luego quemas la casa vieja. Lento y costoso.
- Mover (Move): Es simplemente cambiar la dirección en tu DNI. La casa es la misma, los muebles son los mismos, solo ha cambiado quién es el dueño. Rápido y barato.
El constructor de movimiento
Para implementar esto, C++ nos permite definir un constructor especial: el Move Constructor.
Este constructor no recibe un const T& (referencia a constante), sino un T&& (referencia R-value). Es decir, solo se activará cuando le pasemos un objeto temporal.
Vamos a verlo con una clase Buffer simplificada:
class Buffer {
private:
int* data;
size_t size;
public:
// Constructor normal
Buffer(size_t n) : size(n), data(new int[n]) { }
// Destructor
~Buffer() { delete[] data; }
// 1. CONSTRUCTOR DE COPIA (Lento)
// Se activa con: Buffer b2 = b1;
Buffer(const Buffer& other) : size(other.size) {
std::cout << "Copiando...\n";
data = new int[size]; // Reserva memoria nueva
std::copy(other.data, other.data + size, data); // Copia los datos
}
// 2. CONSTRUCTOR DE MOVIMIENTO (Rápido)
// Se activa con: Buffer b2 = Buffer(10); (Temporales)
Buffer(Buffer&& other) noexcept : data(other.data), size(other.size) {
std::cout << "Moviendo...\n";
// CRÍTICO: Dejar el objeto original en estado vacío
other.data = nullptr;
other.size = 0;
}
};
Qué ocurre en el constructor de movimiento
Fíjate en lo que hacemos en el bloque 2:
- Robamos el puntero:
data(other.data). Copiamos la dirección de memoria, no el contenido. Ahora nuestro objeto apunta a los datos que ya existían. - Anulamos el origen:
other.data = nullptr. Esto es fundamental.
Si no pusiéramos el origen a nullptr, cuando el objeto temporal (other) se destruya (que ocurrirá casi inmediatamente), su destructor hará delete[] data y borrará la memoria que acabamos de robar. ¡Desastre!
Al ponerlo a nullptr, el destructor del temporal no hará nada (hacer delete de un nulo es seguro), y nosotros nos quedamos con los datos.
Operador de asignación de movimiento
Igual que tenemos un constructor, también necesitamos un operador de asignación de movimiento (operator=). La lógica es idéntica, pero debemos recordar liberar nuestra propia memoria antes de robar la nueva (si es que teníamos alguna).
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
// 1. Liberamos nuestros recursos actuales
delete[] data;
// 2. Robamos los recursos del otro
data = other.data;
size = other.size;
// 3. Dejamos al otro vacio
other.data = nullptr;
other.size = 0;
}
return *this;
}
Fíjate que marcamos estas funciones como noexcept. Es una buena práctica en C++. Si el movimiento lanza excepciones, contenedores como std::vector podrían decidir no usar tu optimización de movimiento por seguridad.
La regla de los cinco
Seguramente conozcas la “Regla de los Tres” de C++ clásico (si defines destructor, copia o asignación, debes definir los tres).
Con C++ moderno, esto evoluciona a la regla de los cinco. Si gestionas recursos manualmente, debes definir (o borrar explícitamente) estas cinco operaciones:
- Destructor
- Constructor de Copia
- Operador de Asignación de Copia
- Constructor de Movimiento
- Operador de Asignación de Movimiento
Si no lo haces, el compilador intentará generarlos por ti, pero si tienes punteros crudos, probablemente lo haga mal (copiando punteros en lugar de datos, etc).
¿Cuándo se usa automáticamente?
El compilador es listo. Si ve que el objeto origen es un R-value (un temporal), usará automáticamente el constructor de movimiento.
Buffer crearBuffer() {
return Buffer(100);
}
int main() {
// Caso A: Copia (porque b1 es un L-value)
Buffer b1(100);
Buffer b2 = b1;
// Caso B: normalmente hay elisión; si no, puede moverse
Buffer b3 = crearBuffer();
}
En el caso B, normalmente se aplica elisión de copia y el objeto se construye directamente en b3. Si no puede aplicarse, el resultado puede moverse sin copiar todos los datos.
¿Qué pasa si quiero mover un L-value?
A veces tenemos una variable con nombre (b1), pero sabemos que no la vamos a usar más. Queremos pasársela a otra función y que esta se quede con sus recursos.
Si hacemos funcion(b1), el compilador usará la copia, porque b1 es un L-value y quiere protegerlo.
¿Cómo le decimos al compilador “Oye, trátame a b1 como si fuera un temporal, permíteme moverlo”? Para eso existe una función de la librería estándar.