CodeGym /Cursos /C++ SELF /Destructor virtual

Destructor virtual

C++ SELF
Nivel 51, Lección 2
Disponible

1. El destructor como parte del contrato de la clase

Cuando empiezas, el destructor parece algo aburrido: «vale, hay una función ~Class() que se llama al destruir». Pero en C++ real el destructor es el punto en el que el objeto cierra sus puertas: libera memoria, cierra ficheros, libera bloqueos, anexa al log, etc.

Si ya conoces RAII, la idea es familiar: el recurso vive dentro del objeto-gestor y se libera en el destructor.

Ahora imagina que tienes una jerarquía y almacenas objetos a través del tipo base (a través de Base*, Base&, std::unique_ptr<Base>). Entonces la pregunta «¿qué destructor se llamará?» deja de ser filosófica y pasa a ser «¿se filtrará el recurso / se romperá el programa?».

Modelo de delete: dos pasos

Para entender el problema, mantenemos un modelo simple y honesto. Cuando escribes delete p;, donde p es un puntero, suceden dos cosas grandes:

  1. Se llama al destructor del objeto.
  2. Luego se libera la memoria (a grandes rasgos, "devolvemos el trozo de heap").

Y la pregunta clave de la charla: ¿cómo elige C++ qué destructor llamar? ¿Según qué tipo?

Aquí conviene tener en mente dos tipos:

Término Qué significa
Tipo estático El tipo de la variable en el código. Por ejemplo, para Base* p el tipo estático es Base*.
Tipo real del objeto Qué objeto está realmente en memoria: Derived, Cat, Circle, etc.

Y aquí resulta que sin una configuración especial el destructor al delete vía puntero base puede escogerse no según el tipo real.

2. Ejemplo: compila, pero es peligroso

Hagamos un ejemplo pequeño de «cómo la gente rompe el programa por accidente». Supongamos que tenemos el tipo base Shape y el derivado Circle. Queremos borrar figuras vía Shape*.

#include <iostream>

struct Shape {
    virtual double area() const { return 0.0; } // hay polimorfismo
    ~Shape() { std::cout << "~Shape\n"; }       // ERROR: destructor NO es virtual
};

struct Circle : Shape {
    ~Circle() { std::cout << "~Circle\n"; }
};

int main() {
    Shape* p = new Circle{};
    delete p; // incorrecto: eliminamos el derivado vía la base sin virtual ~Shape()
}

Parece que «no pasa nada, lo eliminamos». Pero por las reglas del lenguaje esto es un uso incorrecto: el comportamiento no está garantizado. En la práctica puedes ver solo ~Shape, puedes no ver ~Circle, puedes tener fuga de recursos, o efectos inesperados.

Es decir, esto no es un "bug lógico" aislado, sino una situación donde el programa deja de ser definido.

4. Por qué: el destructor también debe ser virtual

La idea clave: el destructor también es un método (especial, sin tipo de retorno).

Si borras un objeto vía Base*, el compilador por defecto solo ve "un puntero a Base". Si el destructor no es virtual, entonces se llama al destructor de Base, porque la resolución no virtual se hace por el tipo estático.

Si el destructor es virtual, se activa el mismo mecanismo que con los métodos virtual: la elección se hace por el tipo real del objeto, y la cadena de destrucción será correcta: primero ~Derived(), luego ~Base().

Puedes recordarlo así: el destructor virtual es como "un cartel en la puerta": "si te van a cerrar desde la entrada común — llama al conserje correcto".

5. Regla: destructor virtual en la base polimórfica

La regla en términos prácticos:

Si una clase se usa como base polimórfica (es decir, esperas trabajar con ella vía Base&, Base*, std::unique_ptr<Base>, etc.), entonces debe tener:

virtual ~Base() = default;

¿Por qué = default? Porque la mayoría de las veces no hace falta escribir cuerpo para el destructor base: lo importante es la virtualidad y la semántica correcta, no un std::cout manual o "cerrar fichero" (eso normalmente lo hacen los campos-objetos vía RAII).

Versión mínima "correcta":

struct Shape {
    virtual double area() const { return 0.0; }
    virtual ~Shape() = default; // regla clave
};

6. De malo a bueno

Reescribamos el ejemplo peligroso anterior de forma correcta.

#include <iostream>

struct Shape {
    virtual double area() const { return 0.0; }
    virtual ~Shape() { std::cout << "~Shape\n"; } // ahora virtual
};

struct Circle : Shape {
    ~Circle() override { std::cout << "~Circle\n"; }
};

int main() {
    Shape* p = new Circle{};
    delete p; // ahora correcto
    // ~Circle
    // ~Shape
}

Fíjate en override en el destructor del derivado. No es un "amuleto" obligatorio, pero es una disciplina muy útil: el compilador comprobará que realmente estás sobrescribiendo el destructor virtual de la base, y no has escrito algo parecido por error.

7. std::unique_ptr<Base> también elimina vía la base

Escenario común: "No uso delete, soy moderno y tengo unique_ptr". Perfecto… pero la regla del destructor virtual sigue vigente.

¿Por qué? Porque std::unique_ptr<Base> al destruirse hace aproximadamente esto: delete base_ptr;. Es decir, la eliminación sigue ocurriendo a través del tipo base.

Micro-ejemplo:

#include <iostream>
#include <memory>

struct Shape {
    virtual double area() const { return 0.0; }
    virtual ~Shape() = default; // obligatorio
};

struct Circle : Shape {
    ~Circle() override { std::cout << "~Circle\n"; }
};

int main() {
    std::unique_ptr<Shape> p = std::make_unique<Circle>();
} // ~Circle (luego ~Shape, pero éste es default y no imprime)

Si ~Shape() no fuera virtual, unique_ptr no solucionaría la situación: te salva de usar delete manualmente, pero no invalida la regla de destrucción polimórfica correcta.

8. Ejemplo con colección: mini-registro de figuras

Para que los ejemplos no estén "en el vacío", asumiremos una simple aplicación de consola que almacena figuras y muestra sus áreas. Una colección — distintas implementaciones de comportamiento.

Hacemos la clase base Shape. Punto clave: destructor virtual en la base.

#include <string>

struct Shape {
    virtual std::string name() const { return "Shape"; }
    virtual double area() const { return 0.0; }
    virtual ~Shape() = default; // regla del día
};

Derivados:

#include <string>

struct Rectangle : Shape {
    double w{};
    double h{};
    Rectangle(double width, double height) : w(width), h(height) {}

    std::string name() const override { return "Rectangle"; }
    double area() const override { return w * h; }
};

struct Circle : Shape {
    double r{};
    explicit Circle(double radius) : r(radius) {}

    std::string name() const override { return "Circle"; }
    double area() const override { return 3.14159 * r * r; }
};

Y un pequeño "registro" con std::vector<std::unique_ptr<Shape>>.

#include <iostream>
#include <memory>
#include <vector>

void print_shape(const Shape& s) {
    std::cout << s.name() << ": area=" << s.area() << '\n';
}

int main() {
    std::vector<std::unique_ptr<Shape>> shapes;
    shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
    shapes.push_back(std::make_unique<Circle>(2.0));

    for (const auto& p : shapes) {
        print_shape(*p);
    }
}

Aquí es importante: cuando shapes se destruya al final de main(), cada unique_ptr<Shape> eliminará su objeto "a través de la base". Y por eso virtual ~Shape() — no es un adorno, sino una condición de corrección.

9. Matices útiles

Esquema: qué destructor se llamará

A veces es más fácil ver una vez el diagrama que releer la explicación. Aquí un diagrama de flujo de la elección:

flowchart TD
    A[Hay un puntero Base* p] --> B{¿Se borra con delete p?}
    B --> C{¿~Base es virtual?}
    C -- no --> D[Se llama a ~Base según el tipo estático]
    D --> E[La parte Derived puede no destruirse correctamente]
    C -- sí --> F[Se llama a ~Derived según el tipo real]
    F --> G[Luego se llama automáticamente a ~Base]

La rama superior (no virtual) parece "normal" solo hasta que el derivado tenga recursos. Y los recursos en derivado aparecen rápido: memoria dinámica, descriptor de fichero, socket de red, logger, etc. Por eso la regla es estricta.

Estilo: ~Derived() override = default;

Los principiantes suelen pensar que override es solo para métodos "normales", y que el destructor está como "a parte". Pero si tienes una cadena de herencia, poner override en el destructor del derivado es una buena forma de obligar al compilador a ser estricto.

Si el derivado no necesita hacer nada en el destructor, pero quieres dejar la intención clara, queda así:

struct Shape {
    virtual ~Shape() = default;
};

struct Circle : Shape {
    ~Circle() override = default; // intención explícita
};

Esto es especialmente agradable en código didáctico o de equipo: el lector ve de inmediato que la clase participa en destrucción polimórfica, y no "simplemente hereda".

Si no quieres eliminación polimórfica

A veces (raro, pero ocurre) diseñas una clase base de la que se hereda, pero no quieres que nadie haga delete basePtr;. Por ejemplo, porque los objetos deben vivir solo en la pila, o porque su ciclo de vida lo gestiona un manager externo.

Hoy no vamos a diseñar políticas complejas de propiedad y prohibiciones, pero la idea es: si permites almacenamiento/propiedad vía el tipo base (sobre todo vía smart pointers), debes garantizar la destrucción correcta. Si no quieres eso — el diseño debe expresar la restricción de forma explícita.

En esta lección seguimos un enfoque práctico: si usamos polimorfismo → ponemos destructor virtual.

10. Errores típicos

Error nº1: la clase base tiene métodos virtuales, pero se olvidó hacer el destructor virtual.
Es la combinación más frecuente y peligrosa: la clase parece polimórfica, la empiezan a almacenar vía Base* o std::unique_ptr<Base>, y al destruirse parte del objeto "no llega a destruirse". La prevención habitual es escribir virtual ~Base() = default; tan pronto como en la base aparezca el primer método virtual.

Error nº2: confiar en que "el smart pointer lo arreglará".
std::unique_ptr efectivamente evita el delete manual y fugas en salidas tempranas, pero no cambia la regla del lenguaje: si unique_ptr<Base> destruye el objeto, lo hace vía el puntero base. Por eso el destructor virtual es responsabilidad del diseño de la clase base, no del propietario.

Error nº3: intentar "arreglar" solo en el derivado.
A veces se piensa: "pero si yo hice ~Derived(), todo está bien". Pero el problema no es la falta de destructor en el derivado, sino que al borrar vía Base* el flujo puede no llegar al derivado. La corrección siempre empieza en la base: virtual ~Base().

Error nº4: confusión con override por discrepancia de firma.
Si escribes ~Derived() override y el compilador da error — es una buena noticia, no un castigo. Significa que el destructor base no es virtual o que hay algo mal en la declaración. override sirve para atrapar estas situaciones temprano, no en producción.

Error nº5: "comprobar por cout que todo está bien" y sacar conclusiones de una sola ejecución.
Aunque veas en la consola el orden "correcto" de mensajes, eso no demuestra corrección cuando el destructor no es virtual. En el caso problemático el comportamiento no está garantizado: hoy "tuviste suerte", mañana no. En asuntos de destrucción de objetos polimórficos conviene no confiar en la suerte, sino en la regla del lenguaje: destructor virtual en la base polimórfica.

1
Tarea
C++ SELF, nivel 51, lección 2
Bloqueada
Zoológico de la memoria
Zoológico de la memoria
1
Tarea
C++ SELF, nivel 51, lección 2
Bloqueada
Propietario inteligente
Propietario inteligente
1
Tarea
C++ SELF, nivel 51, lección 2
Bloqueada
Corrección de la eliminación
Corrección de la eliminación
1
Tarea
C++ SELF, nivel 51, lección 2
Bloqueada
Cola de tareas
Cola de tareas
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION