AulaML Python · C++
C++ avanzado · 48 min · C++ moderno

Move semantics, optional y variant

Transferir recursos sin copiarlos y representar valores que pueden faltar o cambiar de tipo

Copiar un vector grande cuesta tiempo y memoria; moverlo solo transfiere su puntero interno. Aquí verás cómo funcionan los rvalue, cómo escribir un constructor de movimiento correcto y cómo expresar «puede no haber valor» o «puede ser de varios tipos» sin punteros sueltos.

Lección 2 de 3 del módulo
Al terminar esta lección podrás
  • Distinguir lvalue de rvalue y explicar qué hace std::move en realidad
  • Escribir un constructor y una asignación de movimiento con noexcept
  • Reconocer cuándo el compilador elide la copia y cuándo no
  • Representar ausencia de valor con std::optional sin usar punteros
  • Guardar alternativas de tipo en std::variant y visitarlas

Antes necesitas: Regla de 0/3/5 y sobrecarga de operadores · RAII y punteros inteligentes

En esta lección
  1. ¿Qué es un rvalue?
  2. ¿Para qué sirve mover?
  3. Cómo funciona por dentro: robo de recursos y RVO
  4. Sintaxis: std::move, constructor de movimiento y forwarding
  5. Ejemplo básico: mover un buffer
  6. Ejemplo intermedio: optional y variant
  7. Caso práctico: un valor de configuración
  8. Visualización: el robo de un recurso
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Un std::vector de un millón de double guarda sus datos en el heap y en el stack solo lleva tres cosas: el puntero al buffer, el tamaño y la capacidad. Copiarlo implica reservar otro millón de double y copiarlos uno a uno. Moverlo consiste en copiar esos tres valores y dejar el original vacío. Esa es toda la idea de move semantics.

El lenguaje necesita distinguir entre un objeto que seguirás usando y uno que ya no necesitas. A esa distinción la llamamos categoría de valor: un lvalue tiene nombre y dirección estable; un rvalue es un temporal que está a punto de desaparecer. std::move no mueve nada: convierte una expresión en un rvalue para que el compilador elija la versión de movimiento.

¿Qué es un rvalue?

Mira la expresión int x = 1 + 2;. x es un lvalue: puedes tomar su dirección, asignarle otro valor y seguir usándolo. 1 + 2 es un rvalue: no tiene nombre ni dirección propia, existe solo mientras se evalúa la expresión. Del mismo modo, std::string("hola") es un temporal, y el resultado de una función que devuelve por valor también lo es.

La biblioteca estándar aprovecha esa diferencia con la sobrecarga: si una función recibe T&, solo acepta lvalues; si recibe T&&, solo acepta rvalues. Así un contenedor puede copiar cuando le pasas algo que vas a conservar y robar los recursos cuando le pasas un temporal que ya nadie más va a usar.

CPP categorias.cpp
#include <iostream>
#include <utility>

void tipo(int&)  { std::cout << "lvalue\n"; }
void tipo(int&&) { std::cout << "rvalue\n"; }

int main() {
    int x = 1;

    tipo(x);             // lvalue: tiene nombre
    tipo(2);             // rvalue: literal temporal
    tipo(std::move(x));  // rvalue: lo declaramos prescindible
    return 0;
}

¿Para qué sirve mover?

  • Evitar copias costosas: devolver un vector de una función, insertarlo en otro contenedor o reasignarlo no duplica el buffer.
  • Transferir propiedad: un unique_ptr no se puede copiar, pero sí mover; el movimiento expresa «ahora el recurso es tuyo».
  • Hacer eficientes los contenedores: cuando un vector crece, mueve sus elementos al buffer nuevo si el movimiento es noexcept; si no, los copia.
  • Separar lectura de consumo: una función que recibe const T& observa; una que recibe T&& puede quedarse con el recurso.
  • Modelar resultados: std::optional evita valores centinela y std::variant guarda alternativas sin jerarquía de clases.
SituaciónQué hacerPor qué
Vas a seguir usando el objetoCopiar o pasar const T&Mover lo dejaría en estado no especificado
Es un temporal o ya no lo necesitasMover o dejar que el compilador elidaSe transfiere el recurso en vez de duplicarlo
Devuelves una local por valorreturn local;RVO o movimiento implícito; std::move lo estorba
Guardas en un contenedorpush_back(std::move(obj))Evita copiar el buffer interno
El tipo es trivial (int, double)Copiar sin preocuparteMover y copiar cuestan lo mismo

Cómo funciona por dentro: robo de recursos y RVO

Un constructor de movimiento recibe T&&, copia los punteros del origen y deja el origen en un estado válido pero vacío. «Válido» significa que puedes destruirlo o asignarle un valor nuevo; «no especificado» significa que no debes asumir que conserva el contenido anterior. Para un std::string, el estándar permite que el origen quede vacío, pero no lo garantiza en todas las implementaciones.

El movimiento debe ser noexcept siempre que sea posible. std::vector lo comprueba: al reasignar, si el constructor de movimiento puede lanzar, prefiere copiar para mantener la garantía fuerte de excepción. Un noexcept ausente puede convertir una operación lineal en una copia completa sin que te des cuenta.

La otra pieza es la elisión de copia. Desde C++17, Buffer b = crear(); construye el objeto directamente en el destino: no hay copia ni movimiento que elidir, es obligatorio. Por eso nunca escribas return std::move(local); dentro de una función que devuelve por valor: impides la elisión y fuerzas un movimiento que no hacía falta.

Sintaxis: std::move, constructor de movimiento y forwarding

Un constructor de movimiento se declara T(T&& otro) noexcept. Si escribes uno, el compilador no genera la copia; si quieres prohibirla, bórrala con = delete. La asignación de movimiento debe liberar el recurso actual antes de robar el del otro y comprobar la autoasignación.

El forwarding perfecto aparece en plantillas que reenvían argumentos sin perder su categoría. T&& en un contexto deducido es una referencia universal: si le pasas un lvalue, T se deduce como U& y la referencia colapsa a U&; si le pasas un rvalue, T es U y queda U&&. std::forward<T> conserva esa categoría al llamar a la siguiente función.

CPP forwarding.cpp
#include <iostream>
#include <utility>

void procesar(int&)  { std::cout << "lvalue\n"; }
void procesar(int&&) { std::cout << "rvalue\n"; }

template <typename T>
void envolver(T&& valor) {
    procesar(std::forward<T>(valor));
}

int main() {
    int x = 1;
    envolver(x);   // lvalue
    envolver(2);   // rvalue
    return 0;
}

Ejemplo básico: mover un buffer

CPP buffer.cpp
#include <cstddef>
#include <iostream>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : datos_(new int[n]), n_(n) {
        std::cout << "constructor " << n_ << '\n';
    }
    ~Buffer() { delete[] datos_; }

    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;

    Buffer(Buffer&& otro) noexcept : datos_(otro.datos_), n_(otro.n_) {
        otro.datos_ = nullptr;
        otro.n_ = 0;
        std::cout << "move " << n_ << '\n';
    }

    Buffer& operator=(Buffer&& otro) noexcept {
        if (this != &otro) {
            delete[] datos_;
            datos_ = otro.datos_;
            n_ = otro.n_;
            otro.datos_ = nullptr;
            otro.n_ = 0;
        }
        return *this;
    }

    std::size_t tamano() const { return n_; }

private:
    int* datos_;
    std::size_t n_;
};

Buffer crear(std::size_t n) {
    return Buffer(n);   // elision garantizada: no hay move
}

int main() {
    Buffer a(4);
    Buffer b(std::move(a));
    std::cout << a.tamano() << ' ' << b.tamano() << '\n'; // 0 4

    Buffer c = crear(3);
    std::cout << c.tamano() << '\n';                      // 3
    return 0;
}

Al ejecutar este programa, la salida es constructor 4, move 4, 0 4, constructor 3 y 3. Fíjate en que crear(3) imprime el constructor pero ningún move: la elisión construyó c directamente. Esa es la diferencia entre mover y no mover en absoluto.

  1. Buffer a(4) reserva 4 enteros y guarda el puntero en a.
  2. Buffer b(std::move(a)) elige el constructor Buffer&&; b copia el puntero y a se queda con nullptr.
  3. a.tamano() devuelve 0 y b.tamano() devuelve 4: el recurso cambió de dueño.
  4. crear(3) devuelve un prvalue; el compilador construye c en su lugar final sin llamar al constructor de movimiento.
  5. Al final del programa, los destructores liberan cada buffer una sola vez: a libera nullptr y b libera el bloque original.

Ejemplo intermedio: optional y variant

std::optional<T> representa un T que puede no existir. Sustituye a los centinelas (-1, cadena vacía, NaN) y obliga a comprobar antes de leer. Tiene un coste: guarda el valor y un booleano, y no reserva en el heap.

CPP optional.cpp
#include <iostream>
#include <optional>

std::optional<double> dividir(double a, double b) {
    if (b == 0.0) {
        return std::nullopt;
    }
    return a / b;
}

int main() {
    if (auto r = dividir(10.0, 4.0)) {
        std::cout << *r << '\n';        // 2.5
    }

    if (!dividir(1.0, 0.0)) {
        std::cout << "sin resultado\n";
    }

    auto r = dividir(9.0, 3.0);
    std::cout << r.value_or(-1.0) << '\n'; // 3
    return 0;
}

std::variant<A, B, C> guarda exactamente una de las alternativas, sin herencia ni punteros. Es una unión segura: sabe qué tipo contiene y lo destruye correctamente. Para operar sobre él se usa std::visit, que llama a un callable con el valor activo, o std::get_if cuando quieres comprobar sin excepciones.

CPP variant.cpp
#include <iostream>
#include <string>
#include <variant>

using Valor = std::variant<int, double, std::string>;

void imprimir(const Valor& v) {
    std::visit([](const auto& x) {
        std::cout << x << '\n';
    }, v);
}

int main() {
    Valor a = 42;
    Valor b = 3.5;
    Valor c = std::string("modelo");

    imprimir(a);
    imprimir(b);
    imprimir(c);

    std::cout << std::get<double>(b) << '\n'; // 3.5

    if (const auto* p = std::get_if<int>(&a)) {
        std::cout << *p + 1 << '\n';          // 43
    }
    return 0;
}

Caso práctico: un valor de configuración

Imagina un parámetro de un modelo que puede venir como entero, como real o como texto: el número de árboles, la tasa de aprendizaje o el nombre de la función de pérdida. Un variant modela esa situación sin una jerarquía de clases y sin void*. La visita con sobrecargas devuelve una descripción legible.

CPP config.cpp
#include <iomanip>
#include <iostream>
#include <sstream>
#include <string>
#include <variant>

using Config = std::variant<int, double, std::string>;

struct Describir {
    std::string operator()(int v) const {
        return "entero: " + std::to_string(v);
    }
    std::string operator()(double v) const {
        std::ostringstream os;
        os << std::fixed << std::setprecision(1) << v;
        return "real: " + os.str();
    }
    std::string operator()(const std::string& v) const {
        return "texto: " + v;
    }
};

int main() {
    Config a = 100;
    Config b = 0.05;
    Config c = std::string("mse");

    for (const Config& valor : {a, b, c}) {
        std::cout << std::visit(Describir{}, valor) << '\n';
    }
    return 0;
}

La salida es entero: 100, real: 0.1 y texto: mse. Ojo con el redondeo: 0.05 con una cifra decimal se muestra como 0.1 por la regla de redondeo de la biblioteca. Si el formato importa, usa más precisión o formatea en la capa de presentación, no en la lógica.

Visualización: el robo de un recurso

Este mapa muestra qué ocurre con el puntero interno cuando un Buffer se mueve. Ningún byte del array se copia: solo cambia quién lo posee. Avanza paso a paso y compara las direcciones.

Mapa de memoria ·Mover un Buffer transfiere el puntero
1/6
STACK memoria automática
a 0x7ffc1000
{0x5500, 4}
Buffer
↕
HEAP memoria dinámica
bloque[4] 0x5500
4 ints sin inicializar
int[4]
a ──▶ 0x5500 datos_
Buffer a(4) reserva 4 ints en el heap. a guarda el puntero 0x5500 y el tamaño 4.
última acción: `Buffer a(4)` reserva 4 ints en el heap. `a` guarda el puntero 0x5500 y el tamaño 4.
Ver código de esta simulación
Buffer a(4);
Buffer b(std::move(a));
std::cout << a.tamano() << ' ' << b.tamano();
Buffer c = crear(3);

Cada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.

Si en lugar de mover hubieras copiado, el paso 3 tendría un bloque nuevo en otra dirección y los dos objetos serían independientes. Mover no reduce el trabajo de reservar: lo elimina. Esa es la razón por la que importa en rutas calientes.

Errores comunes

  • Creer que std::move mueve: solo cambia la categoría de la expresión. Si el tipo no tiene move, se copia y el programa sigue siendo correcto, solo más lento.
  • Usar el objeto de origen después de moverlo: su estado es válido pero no especificado. No asumas que conserva el contenido ni que está vacío.
  • Escribir return std::move(local);: impide la elisión de copia y puede empeorar el rendimiento. Devuelve local directamente.
  • Mover un objeto const: std::move(const T) produce const T&&, que no enlaza con T&&; el constructor de copia se elige y no hay transferencia.
  • Olvidar noexcept en el move: std::vector copiará al crecer. Es una penalización silenciosa y fácil de medir con un contador.
  • Acceder a un optional vacío con * o value(): * es comportamiento indefinido y value() lanza std::bad_optional_access. Comprueba con if o usa value_or.
  • Usar std::get<T> con el tipo equivocado en un variant: lanza std::bad_variant_access. Si el tipo es dinámico, usa std::get_if o std::visit.
Mover el retorno no ayuda
Incorrecto
PYTHON Incorrecto: bloquea la elisión
std::vector<int> crear() {
    std::vector<int> v(1000);
    return std::move(v);
}
Correcto
PYTHON Correcto: elisión o movimiento implícito
std::vector<int> crear() {
    std::vector<int> v(1000);
    return v;
}

El estándar permite y C++17 garantiza la elisión al devolver un prvalue, pero un `std::move` explícito convierte el retorno en una referencia rvalue y obliga a llamar al constructor de movimiento. El resultado es correcto, pero se hace trabajo extra.

Buenas prácticas

  • Marca los constructores y asignaciones de movimiento como noexcept; es la señal que los contenedores necesitan.
  • Deja el origen en un estado válido y documentado: nullptr y tamaño 0 es la convención más clara.
  • Sigue la regla de cero: si puedes usar std::vector, std::string o unique_ptr, no escribas tu propio destructor ni tu propio move.
  • Recibe const T& para observar y T&& solo cuando vayas a quedarte con el recurso.
  • Usa std::forward<T> en plantillas que reenvían; std::move siempre convertiría, incluso los lvalues.
  • Prefiere optional a punteros para valores ausentes y variant a jerarquías cuando las alternativas son cerradas.
  • Visita el variant con std::visit y sobrecargas; evita cadenas de holds_alternative que crecen con cada tipo nuevo.
Ausencia de valor en Python y en C++
Python
PYTHON opcional.py
def dividir(a, b):
    if b == 0:
        return None
    return a / b

r = dividir(10, 4)
print(r if r is not None else "sin resultado")
C++
CPP opcional.cpp
std::optional<double> dividir(double a, double b) {
    if (b == 0.0) return std::nullopt;
    return a / b;
}

auto r = dividir(10.0, 4.0);
if (r) std::cout << *r;

`None` es un valor universal y el tipo no avisa de que la función puede no devolver nada. `std::optional` forma parte de la firma: quien llama sabe que debe comprobar, y el compilador no permite usar el valor sin desenvolverlo.

Ejercicios

Ejercicio 1

Mueve un buffer con asignación

Implementa una clase Buffer con un array dinámico de int, constructor por tamaño, destructor, copia prohibida, constructor de movimiento y asignación de movimiento. En main, crea a(5) y b(2), asigna b = std::move(a) y comprueba que a queda con tamaño 0 y b con 5. Evita fugas y autoasignación.
Pista

En la asignación, libera el array actual antes de copiar el puntero del otro, pero solo si this != &otro. Anula el origen al final.

Ejercicio 2

Describe un variant de configuración

Define using Config = std::variant<int, double, std::string>; y una función std::string describir(const Config&) que devuelva entero: N, real: X con una cifra decimal o texto: S. Implementa la visita con un struct de sobrecargas y pruébala con los tres tipos.
Pista

Un struct con tres operator() const permite que std::visit elija la sobrecarga correcta. Para el double, usa std::ostringstream con std::fixed y std::setprecision(1).

Completar código Completa el código
std::<double> resultado = dividir(8.0, 2.0);

if (resultado) {
    std::cout << resultado;   // 4
}

std::string nombre = "modelo";
std::string copia = std::(nombre);   // transferir, no copiar

El tipo que puede no tener valor es `optional`; para leerlo se desreferencia; la función que convierte a rvalue es `std::move`.

Soluciones explicadas

En el ejercicio del buffer, la parte delicada es la asignación: si liberas antes de comprobar la autoasignación, b = std::move(b) destruiría su propio recurso y luego lo copiaría de sí mismo. La comprobación de identidad evita ese caso. Anular el origen no es opcional: si el destructor de a ejecutara delete[] sobre el mismo puntero que ahora usa b, tendrías una doble liberación.

En el variant, el struct de sobrecargas hace que el compilador elija la función según el tipo activo. La alternativa sería una lambda genérica con if constexpr, útil cuando las tres ramas son muy parecidas; con lógica distinta, las sobrecargas separadas se leen mejor. Si añades un tipo y olvidas la sobrecarga, el error de compilación es la señal de que el visitante está incompleto.

Resumen

  • Un lvalue tiene nombre y dirección; un rvalue es un temporal o algo declarado prescindible.
  • std::move es una conversión a T&&; el movimiento real lo hace el constructor o la asignación.
  • Un move transfiere punteros y deja el origen válido pero sin recursos.
  • noexcept en el move permite que los contenedores muevan en lugar de copiar al crecer.
  • La elisión de copia construye el objeto en su destino; no escribas return std::move(local).
  • std::optional modela un valor que puede faltar y obliga a comprobarlo.
  • std::variant guarda una alternativa cerrada y std::visit la procesa sin herencia.

Evaluación

Evaluación 1

¿Qué hace exactamente std::move(x)?

Evaluación 2

Después de std::string a = "modelo"; std::string b = std::move(a);, ¿qué puedes afirmar de a?

Evaluación 3

¿Cuál es la forma correcta de devolver un vector local por valor?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.