Regla de 0/3/5 y sobrecarga de operadores
Controlar copia, movimiento y destrucción para que un recurso tenga un solo dueño
Cuando una clase posee un recurso, la copia que genera el compilador duplica el puntero y no el recurso: el resultado es una doble liberación. Aquí aprendes a decidir entre regla de 0, 3 o 5, a escribir move correctamente y a sobrecargar `+`, `==` y `<<` sin ambigüedades.
- Distinguir copia superficial de copia profunda con un recurso propio
- Decidir cuándo aplicar la regla de 0, de 3 o de 5
- Escribir constructor de copia, asignación, movimiento y destructor coherentes
- Sobrecargar `+`, `==` y `<<` con la firma correcta
- Explicar qué queda en un objeto después de moverlo
Antes necesitas: Clases, constructores y encapsulamiento · Herencia, virtual y polimorfismo
En esta lección
- ¿Qué es la regla de 0/3/5?
- ¿Para qué sirve controlar estos miembros?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: sumar e imprimir vectores
- Ejemplo intermedio: regla de 3 con un buffer
- Caso práctico: regla de 5 con movimiento
- Visualización: copia profunda y movimiento
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Una clase que guarda un std::vector no necesita preocuparse por nada: el vector copia sus elementos y libera su buffer. Una clase que guarda un int* obtenido con new sí. El compilador genera copia, asignación y destructor por su cuenta, y esa versión automática copia el puntero, no el bloque al que apunta. El resultado son dos objetos que creen poseer la misma memoria.
La regla de 0/3/5 es una guía de decisión: si no gestionas recursos, no escribas ninguno de los cinco miembros especiales; si gestionas uno, escribe los tres de copia o los cinco con movimiento. La sobrecarga de operadores es la otra cara: permitir que tus tipos se usen con la sintaxis natural sin romper la semántica de valor.
¿Qué es la regla de 0/3/5?
Los cinco miembros especiales son el destructor, el constructor de copia, el operador de asignación de copia, el constructor de movimiento y el operador de asignación de movimiento. El compilador los genera si puede, y su versión automática hace una copia miembro a miembro o un movimiento miembro a miembro.
- Regla de 0: no declares ninguno; deja que
std::vector,std::stringystd::unique_ptrgestionen sus recursos. Es la opción preferida. - Regla de 3: si necesitas destructor, constructor de copia o asignación de copia, probablemente necesitas los tres.
- Regla de 5: añade el constructor y la asignación de movimiento para evitar copias costosas.
- Copia profunda: reservar memoria nueva y copiar el contenido, no la dirección.
- Movimiento: transferir el puntero y dejar el origen en un estado válido pero vacío.
¿Para qué sirve controlar estos miembros?
- Evitar la doble liberación: dos objetos con el mismo puntero llaman dos veces a
delete[]y el programa aborta. - Evitar fugas: una asignación que sobrescribe el puntero sin liberar el bloque anterior pierde esa memoria para siempre.
- Hacer copias independientes: modificar la copia no debe cambiar el original.
- Mover sin copiar: devolver un buffer grande de una función debe transferir la propiedad, no duplicar megabytes.
- Integrarse con la biblioteca:
std::vectormueve tus objetos al crecer solo si el movimiento está bien declarado.
¿Cómo funciona por dentro?
Un objeto con un int* ocupa lo que ocupa el puntero más el tamaño. El buffer vive aparte, en el heap. La copia automática copia los bytes del objeto: el puntero del original y el de la copia tienen el mismo valor. Al destruirse ambos, cada uno ejecuta delete[] sobre la misma dirección. Ese es el escenario clásico de free(): double free detected.
La copia profunda rompe esa igualdad: reserva un bloque nuevo, copia elemento a elemento y guarda la dirección nueva. Ahora hay dos bloques y dos dueños. La asignación añade una dificultad: el destino ya posee un bloque que hay que liberar después de leer el origen.
El movimiento es una copia del puntero más la anulación del origen: datos_ = otro.datos_; otro.datos_ = nullptr;. El origen queda vacío y su destructor no libera nada. std::move no mueve nada por sí mismo: solo convierte la expresión en un valor que puede enlazarse a una referencia T&&, y el constructor de movimiento hace el resto.
| Si defines... | Necesitas también... | Motivo |
|---|---|---|
| Nada (regla de 0) | Nada | Los miembros RAII ya copian y liberan correctamente |
| Destructor | Constructor de copia y asignación | La copia automática compartiría el recurso |
| Constructor de copia | Asignación y destructor | Misma razón: hay propiedad que controlar |
| Los tres de copia | Movimiento (regla de 5) | Rendimiento y transferencia de propiedad |
Sintaxis
class Vector2 {
public:
Vector2(double x, double y) : x_(x), y_(y) {}
Vector2(const Vector2& otro) = default; // copia
Vector2& operator=(const Vector2& otro) = default; // asignacion
Vector2(Vector2&& otro) noexcept = default; // movimiento
Vector2& operator=(Vector2&& otro) noexcept = default;
~Vector2() = default; // regla de cero
Vector2 operator+(const Vector2& otro) const {
return Vector2{x_ + otro.x_, y_ + otro.y_};
}
friend std::ostream& operator<<(std::ostream& os, const Vector2& v);
private:
double x_;
double y_;
};operator+ devuelve por valor un objeto nuevo: es la semántica correcta, porque el resultado no existe en ningún otro sitio. operator= devuelve Vector2& para permitir cadenas como a = b = c. operator<< se declara libre y amiga porque su operando izquierdo es un std::ostream, no un Vector2.
Los operadores que modifican el objeto (+=, -=, ++) suelen ser miembros y devuelven *this. Los que producen un valor nuevo (+, -, ==) pueden ser miembros o libres. Los de flujo (<<, >>) deben ser libres. Y un operador debe respetar la semántica que su símbolo ya tiene: si + modifica el objeto, el lector se equivocará.
class Vector2:
def __init__(self, x, y):
self.x, self.y = x, y
def __add__(self, otro):
return Vector2(self.x + otro.x, self.y + otro.y)
def __eq__(self, otro):
return (self.x, self.y) == (otro.x, otro.y)
def __repr__(self):
return f"({self.x}, {self.y})"
print(Vector2(1, 2) + Vector2(3, 4)) # (4, 6)class Vector2 {
public:
Vector2(double x, double y) : x_(x), y_(y) {}
Vector2 operator+(const Vector2& o) const {
return Vector2{x_ + o.x_, y_ + o.y_};
}
bool operator==(const Vector2& o) const {
return x_ == o.x_ && y_ == o.y_;
}
friend std::ostream& operator<<(std::ostream& os, const Vector2& v) {
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
private:
double x_, y_;
};Python usa métodos especiales con nombre fijo (`__add__`, `__eq__`, `__repr__`). C++ usa la palabra `operator` seguida del símbolo, y distingue entre operadores miembro y libres. En ambos casos, la regla es la misma: el operador debe comportarse como el símbolo sugiere.
Ejemplo básico: sumar e imprimir vectores
#include <iostream>
class Vector2 {
public:
Vector2(double x, double y) : x_(x), y_(y) {}
Vector2 operator+(const Vector2& otro) const {
return Vector2{x_ + otro.x_, y_ + otro.y_};
}
bool operator==(const Vector2& otro) const {
return x_ == otro.x_ && y_ == otro.y_;
}
friend std::ostream& operator<<(std::ostream& os, const Vector2& v) {
return os << '(' << v.x_ << ", " << v.y_ << ')';
}
private:
double x_;
double y_;
};
int main() {
const Vector2 a{1.5, 2.0};
const Vector2 b{0.5, 3.0};
std::cout << a + b << '\n';
std::cout << (a == b) << '\n';
return 0;
}a + bllama aoperator+conbcomo argumento y devuelve unVector2nuevo por valor.- El objeto temporal se pasa a
operator<<, que es libre y amigo: puede leerx_ey_porque la clase lo autoriza. operator<<devuelve el mismostd::ostream, lo que permite encadenar más escrituras.a == bcompara estado y devuelvefalse, que se imprime como0.
La salida es (2, 5) y 0. El temporal creado por a + b se destruye al final de la expresión completa, después de imprimirse; no hay ninguna fuga. Y operator<< no necesita ser amigo si expones x() e y(), pero ser amigo evita getters solo para imprimir.
Ejemplo intermedio: regla de 3 con un buffer
Aquí el recurso es un array de enteros reservado con new[]. El destructor lo libera, así que hay que escribir copia y asignación: sin ellas, el compilador generaría una copia superficial y el programa liberaría el mismo bloque dos veces.
#include <cstddef>
#include <iostream>
class Buffer {
public:
explicit Buffer(std::size_t n) : datos_(new int[n]), n_(n) {
for (std::size_t i = 0; i < n_; ++i) {
datos_[i] = static_cast<int>(i + 1);
}
}
Buffer(const Buffer& otro) : datos_(new int[otro.n_]), n_(otro.n_) {
for (std::size_t i = 0; i < n_; ++i) {
datos_[i] = otro.datos_[i];
}
}
Buffer& operator=(const Buffer& otro) {
if (this != &otro) {
int* nuevos = new int[otro.n_];
for (std::size_t i = 0; i < otro.n_; ++i) {
nuevos[i] = otro.datos_[i];
}
delete[] datos_;
datos_ = nuevos;
n_ = otro.n_;
}
return *this;
}
~Buffer() { delete[] datos_; }
int& operator[](std::size_t i) { return datos_[i]; }
const int& operator[](std::size_t i) const { return datos_[i]; }
std::size_t size() const { return n_; }
private:
int* datos_;
std::size_t n_;
};
int main() {
Buffer a(3);
Buffer b = a; // copia profunda
b[0] = 99;
std::cout << a[0] << ' ' << b[0] << '\n';
std::cout << a.size() << '\n';
return 0;
}La salida es 1 99 y 3. Modificar b[0] no toca a porque son bloques distintos. La asignación reserva primero el bloque nuevo, copia, y solo entonces libera el antiguo: si liberara antes y el origen fuera el mismo objeto, estaría leyendo memoria ya devuelta. La comprobación this != &otro evita justamente ese caso.
Caso práctico: regla de 5 con movimiento
Copiar un buffer grande cuesta tiempo y memoria; moverlo solo copia dos campos. La clase Secuencia implementa los cinco miembros: copia profunda, asignación con copy-and-swap, movimiento que transfiere el puntero y destructor que libera si queda algo. El operador << muestra el contenido sin exponer el puntero.
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
class Secuencia {
public:
explicit Secuencia(std::size_t n) : datos_(new double[n]), n_(n) {
for (std::size_t i = 0; i < n_; ++i) {
datos_[i] = static_cast<double>(i);
}
}
Secuencia(const Secuencia& otro) : datos_(new double[otro.n_]), n_(otro.n_) {
std::copy(otro.datos_, otro.datos_ + otro.n_, datos_);
}
Secuencia& operator=(const Secuencia& otro) {
if (this != &otro) {
Secuencia tmp(otro);
std::swap(datos_, tmp.datos_);
std::swap(n_, tmp.n_);
}
return *this;
}
Secuencia(Secuencia&& otro) noexcept : datos_(otro.datos_), n_(otro.n_) {
otro.datos_ = nullptr;
otro.n_ = 0;
}
Secuencia& operator=(Secuencia&& otro) noexcept {
if (this != &otro) {
delete[] datos_;
datos_ = otro.datos_;
n_ = otro.n_;
otro.datos_ = nullptr;
otro.n_ = 0;
}
return *this;
}
~Secuencia() { delete[] datos_; }
std::size_t size() const { return n_; }
friend std::ostream& operator<<(std::ostream& os, const Secuencia& s) {
os << '[';
for (std::size_t i = 0; i < s.n_; ++i) {
if (i > 0) {
os << ' ';
}
os << s.datos_[i];
}
return os << ']';
}
private:
double* datos_;
std::size_t n_;
};
int main() {
Secuencia a(3);
Secuencia b = a;
Secuencia c = std::move(a);
std::cout << b << '\n';
std::cout << c << '\n';
std::cout << a.size() << ' ' << c.size() << '\n';
return 0;
}La salida es [0 1 2], [0 1 2] y 0 3. b es una copia independiente; c se queda con el bloque de a y a queda vacía, con tamaño 0. El destructor de a no libera nada porque el puntero es nullptr, y delete[] nullptr es una operación legal que no hace nada.
La asignación de copia usa copy-and-swap: construye una copia temporal y luego intercambia los punteros. Si la reserva falla, el original queda intacto, y el temporal libera el bloque viejo al salir. Funciona incluso cuando el origen es el propio objeto.
Visualización: copia profunda y movimiento
La simulación sigue el mismo Buffer del ejemplo intermedio. En el paso de la copia, compara las direcciones: si fueran iguales, sería una copia superficial y el programa fallaría al destruir. En el movimiento, observa que no se reserva memoria nueva: solo cambia el dueño del bloque.
a ──▶ 0x9000 poseenew int[4] y guarda la dirección 0x9000 en datos_. El objeto a vive en el stack.Ver código de esta simulación
Buffer a(4);
Buffer b = a;
b[0] = 9;
Buffer c = std::move(a);Cada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.
Si en el segundo paso las dos flechas apuntaran a 0x9000, la copia sería superficial: b y a compartirían bloque, el cambio de b[0] afectaría a a y la destrucción liberaría dos veces la misma dirección. Todo el capítulo se reduce a evitar que eso ocurra.
Errores comunes
| Error | Causa | Solución |
|---|---|---|
free(): double free detected | Copia superficial: dos objetos con el mismo puntero | Copia profunda o unique_ptr/vector en lugar de new[] |
| Fuga al asignar sobre un objeto con recurso | Se sobrescribe el puntero sin delete[] previo | Libera el anterior o usa copy-and-swap |
error: use of deleted function al copiar un unique_ptr | unique_ptr no se puede copiar porque tiene un solo dueño | Mueve con std::move o clona el recurso |
error: 'operator<<' must take exactly one argument | Se declaró como miembro y recibía dos | Decláralo libre (y friend si necesita acceso privado) |
warning: reference to local variable returned | Un operador devuelve const T& a un temporal | Devuelve por valor los operadores que crean objetos |
El primero solo aparece en ejecución; los demás son errores o avisos del compilador. Prueba con AddressSanitizer (-fsanitize=address): detecta la doble liberación en el momento en que ocurre.
#include <iostream>
class Punto {
public:
Punto(int x, int y) : x_(x), y_(y) {}
bool operator==(const Punto& otro) const {
return this == &otro; // solo true si es el mismo objeto
}
private:
int x_;
int y_;
};
int main() {
const Punto a{1, 2};
const Punto b{1, 2};
std::cout << (a == b) << '\n';
return 0;
}#include <iostream>
class Punto {
public:
Punto(int x, int y) : x_(x), y_(y) {}
bool operator==(const Punto& otro) const {
return x_ == otro.x_ && y_ == otro.y_;
}
private:
int x_;
int y_;
};
int main() {
const Punto a{1, 2};
const Punto b{1, 2};
std::cout << (a == b) << '\n';
return 0;
}La primera versión imprime `0`: dos puntos con las mismas coordenadas no son iguales porque están en direcciones distintas. La segunda imprime `1`. La igualdad de un tipo de valor debe comparar su estado; para identidad ya existe comparar direcciones con `&a == &b`.
Buenas prácticas
- Prefiere la regla de 0: usa
std::vector,std::stringy punteros inteligentes como miembros. - Si gestionas un recurso, escribe los cinco miembros o ninguno; nunca solo el destructor.
- Reserva antes de liberar en la asignación, o usa copy-and-swap.
- Comprueba
this != &otroen la asignación o evita el problema con el intercambio. - Marca el movimiento como
noexceptpara que los contenedores lo aprovechen. - Devuelve por valor los operadores que crean objetos; por referencia, solo
*this. - Define
operator<<como función libre y no lo uses para lógica de negocio.
Ejercicios
Fracciones con operadores
Racional con numerador y denominador privados que simplifique en el constructor usando std::gcd. Sobrecarga operator+, operator== y operator<< para que 1/2 + 1/3 se imprima como 5/6. Comprueba también que 1/2 no es igual a 1/3 y que la suma sí es igual a 5/6.Pista
La suma es (a/b) + (c/d) = (a*d + c*b) / (b*d); construye el resultado con el constructor para que se simplifique solo. std::gcd está en <numeric>.
Arreglo con regla de 5
Arreglo que posea un int* reservado con new[]. Escribe constructor, copia profunda, asignación con copy-and-swap, constructor y asignación de movimiento con noexcept, destructor y operator[] const y no const. Comprueba que la copia es independiente, que el movimiento deja el origen con tamaño 0 y que el destino conserva el valor 7.Pista
En el movimiento, asigna el puntero, copia el tamaño y deja el origen en nullptr y 0. En la asignación de copia, crea un temporal y usa std::swap sobre los dos campos.
class Punto { public: Punto(int x, int y) : x_(x), y_(y) {} Punto +(const Punto& otro) const { return Punto{x_ + otro.x_, y_ + otro.y_}; } std::ostream& operator<<(std::ostream& os, const Punto& p) { return os << p.x_ << ',' << p.y_; } private: int x_; int y_; };
La suma se declara con la palabra que introduce cualquier sobrecarga; el operador de flujo necesita acceso a los miembros privados.
Soluciones explicadas
En el racional, simplificar en el constructor mantiene un invariante: cada fracción se guarda en forma canónica. Gracias a eso, operator== puede comparar campo a campo sin calcular productos cruzados. Si la simplificación estuviera solo en operator+, dos fracciones equivalentes construidas de otra forma no serían iguales, y el bug aparecería mucho después.
En el arreglo, el orden de las operaciones del movimiento es el contrato completo: transferir, copiar el tamaño y anular el origen. Si olvidas anular el puntero, el destructor del origen libera el bloque que ahora usa el destino y el programa falla al final; si olvidas el tamaño, el objeto movido miente sobre su contenido. El noexcept permite que std::vector<Arreglo> mueva en lugar de copiar al crecer.
Resumen
- La copia automática copia el puntero, no el recurso: eso produce dobles liberaciones.
- Regla de 0: no escribas miembros especiales si tus miembros ya gestionan sus recursos.
- Regla de 3: destructor, copia y asignación van siempre juntos.
- Regla de 5: añade movimiento con
noexceptpara transferir en lugar de copiar. - En la asignación, libera después de reservar y copiar, o usa copy-and-swap.
operator+devuelve por valor;operator=devuelve*this;operator<<es libre y amigo.- La igualdad compara estado, no direcciones.
Evaluación
Una clase reserva memoria con new[] en su constructor. ¿Qué debe declarar además del destructor?
Después de Secuencia c = std::move(a);, ¿qué se puede afirmar de a?
¿Por qué operator<< se declara como función libre y no como método de la clase?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.