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.
- 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
- ¿Qué es un rvalue?
- ¿Para qué sirve mover?
- Cómo funciona por dentro: robo de recursos y RVO
- Sintaxis: std::move, constructor de movimiento y forwarding
- Ejemplo básico: mover un buffer
- Ejemplo intermedio: optional y variant
- Caso práctico: un valor de configuración
- Visualización: el robo de un recurso
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- 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.
#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
vectorde una función, insertarlo en otro contenedor o reasignarlo no duplica el buffer. - Transferir propiedad: un
unique_ptrno se puede copiar, pero sí mover; el movimiento expresa «ahora el recurso es tuyo». - Hacer eficientes los contenedores: cuando un
vectorcrece, mueve sus elementos al buffer nuevo si el movimiento esnoexcept; si no, los copia. - Separar lectura de consumo: una función que recibe
const T&observa; una que recibeT&&puede quedarse con el recurso. - Modelar resultados:
std::optionalevita valores centinela ystd::variantguarda alternativas sin jerarquía de clases.
| Situación | Qué hacer | Por qué |
|---|---|---|
| Vas a seguir usando el objeto | Copiar o pasar const T& | Mover lo dejaría en estado no especificado |
| Es un temporal o ya no lo necesitas | Mover o dejar que el compilador elida | Se transfiere el recurso en vez de duplicarlo |
| Devuelves una local por valor | return local; | RVO o movimiento implícito; std::move lo estorba |
| Guardas en un contenedor | push_back(std::move(obj)) | Evita copiar el buffer interno |
El tipo es trivial (int, double) | Copiar sin preocuparte | Mover 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.
#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
#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.
Buffer a(4)reserva 4 enteros y guarda el puntero ena.Buffer b(std::move(a))elige el constructorBuffer&&;bcopia el puntero yase queda connullptr.a.tamano()devuelve 0 yb.tamano()devuelve 4: el recurso cambió de dueño.crear(3)devuelve un prvalue; el compilador construyecen su lugar final sin llamar al constructor de movimiento.- Al final del programa, los destructores liberan cada buffer una sola vez:
aliberanullptryblibera 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.
#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.
#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.
#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.
a ──▶ 0x5500 datos_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::movemueve: 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. Devuelvelocaldirectamente. - Mover un objeto
const:std::move(const T)produceconst T&&, que no enlaza conT&&; el constructor de copia se elige y no hay transferencia. - Olvidar
noexcepten el move:std::vectorcopiará al crecer. Es una penalización silenciosa y fácil de medir con un contador. - Acceder a un
optionalvacío con*ovalue():*es comportamiento indefinido yvalue()lanzastd::bad_optional_access. Comprueba conifo usavalue_or. - Usar
std::get<T>con el tipo equivocado en unvariant: lanzastd::bad_variant_access. Si el tipo es dinámico, usastd::get_ifostd::visit.
std::vector<int> crear() {
std::vector<int> v(1000);
return std::move(v);
}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:
nullptry tamaño 0 es la convención más clara. - Sigue la regla de cero: si puedes usar
std::vector,std::stringounique_ptr, no escribas tu propio destructor ni tu propio move. - Recibe
const T¶ observar yT&&solo cuando vayas a quedarte con el recurso. - Usa
std::forward<T>en plantillas que reenvían;std::movesiempre convertiría, incluso los lvalues. - Prefiere
optionala punteros para valores ausentes yvarianta jerarquías cuando las alternativas son cerradas. - Visita el
variantconstd::visity sobrecargas; evita cadenas deholds_alternativeque crecen con cada tipo nuevo.
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")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
Mueve un buffer con asignación
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.
Describe un variant de configuración
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).
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::movees una conversión aT&&; el movimiento real lo hace el constructor o la asignación.- Un move transfiere punteros y deja el origen válido pero sin recursos.
noexcepten 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::optionalmodela un valor que puede faltar y obliga a comprobarlo.std::variantguarda una alternativa cerrada ystd::visitla procesa sin herencia.
Evaluación
¿Qué hace exactamente std::move(x)?
Después de std::string a = "modelo"; std::string b = std::move(a);, ¿qué puedes afirmar de a?
¿Cuál es la forma correcta de devolver un vector local por valor?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.