Movimiento de objetos
En C++ clásico, copiar un objeto complejo —por ejemplo, un std::vector con miles de elementos— podía ser una operación costosa, ya que implicaba duplicar toda su memoria interna.
Para optimizar este proceso, a partir de C++11 el lenguaje incorporó el movimiento de objetos, que permite transferir recursos de un objeto a otro en lugar de copiarlos.
El movimiento hace posible escribir programas más eficientes, especialmente cuando se trabaja con objetos temporales o con estructuras grandes que no es necesario duplicar.
Valores lvalue y rvalue
Para comprender cómo funciona el movimiento, es necesario distinguir dos categorías fundamentales de expresiones en C++:
-
lvalue (left value): Son expresiones que hacen referencia a una ubicación identificable en memoria, es decir, tienen nombre y pueden aparecer en el lado izquierdo de una asignación. Se puede obtener su dirección con el operador
&.int x = 42; // x es un lvalue int* p = &x; // se puede tomar la dirección de x -
rvalue (right value): Son valores temporales que no tienen nombre ni dirección persistente. No se les puede tomar la dirección (en general) y se usan normalmente en el lado derecho de una asignación.
int y = x + 1; // x + 1 es un rvalue
Con la llegada de C++11, se introdujo una nueva categoría de referencias: las referencias a rvalue (T&&), que permiten capturar estos valores temporales y aprovecharlos para mover recursos en lugar de copiarlos.
Cómo actúa std::move()
La función std::move() es la herramienta que habilita la semántica de movimiento en C++.
Su comportamiento puede resumirse así:
- No mueve nada por sí misma.
- Convierte un lvalue en un rvalue, indicando al compilador que los recursos de ese objeto pueden transferirse en lugar de copiarse.
Por ejemplo:
#include <iostream>
#include <utility> // std::move
#include <vector>
int main() {
std::vector<int> origen = {1, 2, 3, 4, 5};
std::vector<int> destino = std::move(origen); // Se transfiere el contenido
std::cout << "Tamaño de origen: " << origen.size() << "\n";
std::cout << "Tamaño de destino: " << destino.size() << "\n";
}
En este código:
std::move(origen)convierte el objetoorigen(un lvalue) en un rvalue, habilitando el constructor de movimiento destd::vector.- Los recursos internos (el bloque de memoria del vector) se transfieren a
destinosin copiar los elementos. - Tras el movimiento,
origenqueda vacío pero válido: puede volver a asignarse o destruirse sin problemas.
El uso de std::move() no debe confundirse con una copia o reasignación: simplemente cambia el contexto semántico del objeto, indicando que ya no será utilizado en su estado actual.
Constructor y operador de movimiento
El movimiento se implementa mediante dos mecanismos especiales del lenguaje:
- Constructor de movimiento: crea un nuevo objeto transfiriendo los recursos del objeto origen.
- Operador de asignación por movimiento: transfiere los recursos de un objeto existente a otro que ya estaba construido.
Ambos mecanismos se definen utilizando referencias a rvalue (T&&) y suelen declararse como noexcept.
Se suelen declarar los constructores y operadores de movimiento como noexcept porque:
- Al transferir recursos en lugar de copiarlos, normalmente no realizan operaciones que puedan lanzar excepciones (como asignaciones de memoria).
- Garantiza al compilador que el movimiento es seguro.
- Permitiendo a los contenedores de la STL, como
std::vectorostd::map, utilizarlos con confianza al reorganizar o redimensionar sus elementos. - Si el movimiento no estuviera marcado como
noexcept, estos contenedores recurrirían a la copia para mantener la seguridad ante posibles excepciones, perdiendo así las ventajas de eficiencia que ofrece el movimiento.
Ejemplo: Movimiento con std::unique_ptr
En este ejemplo, la clase BufferMovil gestiona su recurso mediante un puntero exclusivo (std::unique_ptr).
El constructor de movimiento y el operador de asignación por movimiento transfieren la propiedad del recurso en lugar de copiarlo.
#include <iostream>
#include <memory>
#include <vector>
class BufferMovil {
private:
std::unique_ptr<std::vector<int>> datos;
public:
// Constructor por defecto
BufferMovil() = default;
// Constructor con datos iniciales (crea un vector con los valores dados)
explicit BufferMovil(const std::vector<int>& v)
: datos(std::make_unique<std::vector<int>>(v)) {}
// Constructor de movimiento
BufferMovil(BufferMovil&& other) noexcept
: datos(std::move(other.datos)) { // std::move(other.datos) convierte el miembro en rvalue
// y activa el constructor de movimiento de std::unique_ptr,
// transfiriendo la propiedad del puntero.
std::cout << "Constructor de movimiento\n";
}
// Operador de asignación por movimiento
BufferMovil& operator=(BufferMovil&& other) noexcept {
if (this != &other) {
datos = std::move(other.datos); // Transfiere la propiedad del puntero del objeto origen
// al destino. El origen queda con un puntero nulo.
std::cout << "Asignación por movimiento\n";
}
return *this;
}
void mostrar() const {
if (!datos || datos->empty()) {
std::cout << "(vacío)\n";
} else {
for (int v : *datos) std::cout << v << " ";
std::cout << "\n";
}
}
};
int main() {
BufferMovil b1(std::vector<int>{1, 2, 3}); // Crea un objeto con su propio recurso
// std::move(b1) convierte b1 (un lvalue) en un rvalue,
// habilitando el constructor de movimiento en lugar del de copia.
BufferMovil b2 = std::move(b1); // Se transfiere la propiedad del recurso de b1 a b2
// (sin copiar el std::vector<int>).
BufferMovil b3; // Objeto inicialmente vacío
b3 = std::move(b2); // Invoca el operador de asignación por movimiento.
// Transfiere el recurso de b2 a b3; b2 queda vacío.
std::cout << "b1: "; b1.mostrar(); // (vacío) → b1 perdió su recurso
std::cout << "b2: "; b2.mostrar(); // (vacío) → b2 también fue movido
std::cout << "b3: "; b3.mostrar(); // 1 2 3 → ahora b3 posee el recurso original
}
std::move(b1)convierteb1en un rvalue, indicando al compilador que puede tratarse como un objeto temporal cuyo contenido puede ser movido en lugar de copiado.- Esta conversión activa el constructor de movimiento
BufferMovil(BufferMovil&& other), ya que el argumento es ahora un rvalue. - Dentro del constructor,
std::move(other.datos)convierte el miembrodatosdel objeto origen en un rvalue, lo que invoca el constructor de movimiento destd::unique_ptr. - El
std::unique_ptrtransfiere la propiedad del puntero interno desdeother.datosal nuevo objeto (this->datos), dejando al objeto origen (b1) con un puntero nulo (nullptr). - Este movimiento de puntero es una transferencia de propiedad, no una copia: no se duplica el
std::vector<int>, solo se reasigna su posesión. - El recurso (
std::vector<int>) pasa a pertenecer exclusivamente ab2, sin que se realicen copias ni asignaciones de memoria. - Tras el movimiento,
b1queda en un estado válido pero vacío: su puntero interno es nulo, pero puede ser destruido o reutilizado sin problema. - El uso de
noexceptindica que este constructor no lanzará excepciones, lo cual permite que contenedores de la STL (comostd::vector<BufferMovil>) puedan mover sus elementos de forma segura y eficiente. - En el caso del operador de asignación por movimiento, la lógica es equivalente: el recurso del objeto origen se transfiere al destino, y el origen se vacía.
- Después de mover un objeto:
- Su estado es válido pero no especificado: puede destruirse, reasignarse o recibir nuevos datos, pero no debe accederse a su contenido anterior.
- No es necesario reinicializarlo manualmente; el movimiento garantiza que el objeto puede ser destruido de forma segura o volver a usarse más adelante.