Polimorfismo dinámico: vtables, override y destructores virtuales

El polimorfismo dinámico es el mecanismo que permite que un mismo puntero o referencia a una clase base (Base* o Base&) ejecute comportamientos distintos dependiendo del tipo real del objeto al que apunta en tiempo de ejecución. Esto se logra mediante el uso de funciones virtuales. Cuando marcas una función como virtual, le estás diciendo al compilador que no debe resolver la llamada en tiempo de compilación (enlace estático), sino que debe buscar la implementación adecuada durante la ejecución (enlace dinámico).

Para que esto funcione, el compilador implementa una estructura interna llamada vtable (tabla de funciones virtuales). Cada clase que contiene al menos una función virtual posee su propia vtable, que es básicamente un array de punteros a las implementaciones de las funciones virtuales de esa clase. Además, cada instancia (objeto) de dicha clase incluye un puntero oculto, el vptr (puntero virtual), que apunta a la vtable correspondiente. Cuando llamas a una función virtual, el programa sigue estos pasos: accede al vptr del objeto, busca la dirección de la función en la vtable y salta a esa dirección.

Debes usar funciones virtuales cuando diseñes jerarquías donde la clase base define una interfaz (a menudo mediante funciones virtuales puras, indicadas con = 0, lo que convierte a la clase en una clase abstracta que no puede instanciarse) y quieres que las subclases personalicen ese comportamiento. Sin embargo, esto tiene un coste: la indirección del puntero impide que el compilador realice la optimización de inlining en esa llamada, y añade un ligero overhead por el acceso a la memoria de la tabla.

Si implementas polimorfismo y olvidas marcar el destructor de la clase base como virtual, el programa incurrirá en comportamiento indefinido (Undefined Behavior) al destruir objetos derivados a través de punteros de la base, dejando recursos en un estado inconsistente o causando fugas de memoria.

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

// Clase abstracta (interface)
class Entidad {
public:
    // El destructor DEBE ser virtual en cualquier clase base de una jerarquía polimórfica.
    virtual ~Entidad() = default;

    // Función virtual pura: hace que Entidad sea una clase abstracta.
    virtual void actualizar() const = 0;

    // Función virtual con implementación por defecto.
    virtual void obtener_info() const {
        std::cout << "Entidad genérica.\n";
    }
};

// Clase derivada que implementa la interfaz
class Jugador final : public Entidad {
public:
    void actualizar() const override {
        std::cout << "Jugador: procesando inputs de teclado...\n";
    }

    void obtener_info() const override {
        std::cout << "Jugador: HP 100, Mana 50.\n";
    }
    // La palabra clave 'final' [C++11] impide que otras clases hereden de Jugador.
};

class Enemigo : public Entidad {
public:
    void actualizar() const override {
        std::cout << "Enemigo: ejecutando IA de persecución...\n";
    }

    // No sobreescribimos obtener_info(), usará la implementación de la base.
};

int main() {
    // Usamos std::unique_ptr para gestión automática de memoria (RAII).
    // El vector almacena punteros a la base, permitiendo el polimorfismo.
    std::vector<std::unique_ptr<Entidad>> mundo;

    mundo.push_back(std::make_unique<Jugador>());
    mundo.push_back(std::make_unique<Enemigo>());

    std::cout << "--- Inicio del loop de actualización ---\n";

    for (const auto& entidad : mundo) {
        // Enlace dinámico: el vptr de cada objeto dirige a su vtable respectiva.
        entidad->actualizar();
        entidad->obtener_info();
        std::cout << "\n";
    }

    return 0;
}

Análisis del código

En el ejemplo, Entidad actúa como nuestra interfaz base. Al declarar virtual void actualizar() const = 0, hemos definido una función virtual pura. Esto garantiza que no se pueda crear un objeto de tipo Entidad directamente, forzando a cualquier clase derivada a proveer su propia lógica para actualizar().

Cuando iteramos sobre std::vector<std::unique_ptr<Entidad>>, la llamada entidad->actualizar() es el núcleo del polimorfismo. Aunque el tipo del puntero es Entidad*, el compilador no sabe en tiempo de compilación si el objeto es un Jugador o un Enemigo. En su lugar, genera código para buscar la dirección de actualizar() en la vtable del objeto actual mediante su vptr. Por eso, cuando el objeto es un Jugador, vemos su mensaje específico.

He marcado la clase Jugador con la palabra clave final [C++11]. Esto tiene dos propósitos: primero, le indica al compilador que no puede haber más herencia de esta clase; segundo, ayuda al optimizador, ya que al saber que no habrá más sobreescritura, el compilador podría realizar una desvirtualización (evitar el salto a la vtable y hacer inline directamente).

En Enemigo, utilizamos override [C++11]. Esto es crucial: si por error hubiéramos escrito void actualizar(int x) const (con un parámetro extra), el compilador nos daría un error. Sin override, el compilador pensaría que es una función nueva y el polimorfismo fallaría silenciosamente, llamando a la base en lugar de a la derivada.

El error frecuente

Un error clásico que solo aparece al ejecutar el programa (o bajo herramientas de detección de memoria como AddressSanitizer) es el destructor no virtual en la clase base.

class Base {
public:
    ~Base() { std::cout << "Destructor de Base\n"; } // <--- ERROR: No es virtual
};

class Derivada : public Base {
    int* data;
public:
    Derivada() : data(new int[100]) {}
    ~Derivada() { 
        std::cout << "Destructor de Derivada\n"; 
        delete[] data; 
    }
};

// ... en el main ...
Base* b = new Derivada();
delete b; 

Si ejecutas esto, solo verás el mensaje "Destructor de Base". El destructor de Derivada nunca se llama, lo que significa que la memoria de data se queda huérfana (leak). Al ser delete b una llamada sobre un puntero de tipo Base, el compilador busca el destructor en la vtable. Si ~Base() no es virtual, el enlace es estático y solo se llama al destructor de la clase del puntero. Siempre, sin excepción, usa virtual ~Base() = default; en tus clases base polimórficas.

42

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio