AulaML Python · C++
C++ avanzado · 48 min · Programación orientada a objetos

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.

Lección 3 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es la regla de 0/3/5?
  2. ¿Para qué sirve controlar estos miembros?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: sumar e imprimir vectores
  6. Ejemplo intermedio: regla de 3 con un buffer
  7. Caso práctico: regla de 5 con movimiento
  8. Visualización: copia profunda y movimiento
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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::string y std::unique_ptr gestionen 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::vector mueve 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)NadaLos miembros RAII ya copian y liberan correctamente
DestructorConstructor de copia y asignaciónLa copia automática compartiría el recurso
Constructor de copiaAsignación y destructorMisma razón: hay propiedad que controlar
Los tres de copiaMovimiento (regla de 5)Rendimiento y transferencia de propiedad

Sintaxis

CPP los cinco miembros y los operadores
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á.

Aritmética de un tipo propio
Python
PYTHON vector.py
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)
C++
CPP vector2.cpp
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

CPP suma.cpp
#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;
}
  1. a + b llama a operator+ con b como argumento y devuelve un Vector2 nuevo por valor.
  2. El objeto temporal se pasa a operator<<, que es libre y amigo: puede leer x_ e y_ porque la clase lo autoriza.
  3. operator<< devuelve el mismo std::ostream, lo que permite encadenar más escrituras.
  4. a == b compara estado y devuelve false, que se imprime como 0.

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.

CPP buffer.cpp
#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.

CPP secuencia.cpp
#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.

Mapa de memoria ·Dos dueños distintos y un puntero que se transfiere
1/5
STACK memoria automática
a 0x7ffc1000
{datos_: 0x9000, n_: 4}
Buffer
↕
HEAP memoria dinámica
a.datos_ 0x9000
1 2 3 4
int[4]
a ──▶ 0x9000 posee
El constructor reserva 4 enteros en el heap con new int[4] y guarda la dirección 0x9000 en datos_. El objeto a vive en el stack.
última acción: El constructor reserva 4 enteros en el heap con `new 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

ErrorCausaSolución
free(): double free detectedCopia superficial: dos objetos con el mismo punteroCopia profunda o unique_ptr/vector en lugar de new[]
Fuga al asignar sobre un objeto con recursoSe sobrescribe el puntero sin delete[] previoLibera el anterior o usa copy-and-swap
error: use of deleted function al copiar un unique_ptrunique_ptr no se puede copiar porque tiene un solo dueñoMueve con std::move o clona el recurso
error: 'operator<<' must take exactly one argumentSe declaró como miembro y recibía dosDecláralo libre (y friend si necesita acceso privado)
warning: reference to local variable returnedUn operador devuelve const T& a un temporalDevuelve 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.

Comparar direcciones no es comparar valores
Incorrecto
PYTHON Incorrecto: compara identidad
#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;
}
Correcto
PYTHON Correcto: compara estado
#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::string y 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 != &otro en la asignación o evita el problema con el intercambio.
  • Marca el movimiento como noexcept para 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

Ejercicio 1

Fracciones con operadores

Escribe una clase 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>.

Ejercicio 2

Arreglo con regla de 5

Implementa una clase 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.

Completar código Completa los operadores
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 noexcept para 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

Evaluación 1

Una clase reserva memoria con new[] en su constructor. ¿Qué debe declarar además del destructor?

Evaluación 2

Después de Secuencia c = std::move(a);, ¿qué se puede afirmar de a?

Evaluación 3

¿Por qué operator<< se declara como función libre y no como método de la clase?

Tus notas

¿Ya lo entendiste?

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