Un constructor es una función miembro especial cuya única misión es preparar un objeto para su uso, asegurando que sus datos internos tengan un valor válido desde el momento en que nace. No tiene tipo de retorno y, de hecho, su nombre debe coincidir exactamente con el de la clase. Por otro lado, el destructor es el encargado de la limpieza: se ejecuta automáticamente cuando un objeto deja de existir (ya sea porque sale de su bloque de código en el stack o porque se libera manualmente en el heap), permitiendo liberar recursos como memoria, punteros o archivos.
Este mecanismo funciona así porque el lenguaje necesita garantizar la integridad del objeto. Si pudieras usar un objeto sin haber inicializado sus variables, estarías leyendo basura de la memoria (comportamiento indefinido). Por eso, el compilador gestiona un flujo de vida muy estricto. Debes usar constructores siempre que necesites asegurar que un objeto nazca con un estado coherente, y destructores para implementar RAII (Resource Acquisition Is Initialization), asegurando que no haya fugas de memoria (memory leaks). Si fallas en el orden de inicialización o te olvidas de un destructor virtual en una clase base, lo que rompes es la lógica de gestión de memoria y el comportamiento del programa se vuelve impredecible, provocando errores que el compilador a veces no puede detectar hasta que el programa ya está en ejecución.
#include <iostream>
#include <string>
#include <utility>
// Base para demostrar el uso de destructores virtuales
class Base {
public:
Base() { std::cout << "[Base] Constructor\n"; }
// El destructor DEBE ser virtual si planeas usar polimorfismo.
// De lo contrario, destruir un objeto derivado a través de un
// puntero de la clase base es Comportamiento Indefinido.
virtual ~Base() { std::cout << "[Base] Destructor virtual\n"; }
};
class Sensor : public Base {
private:
std::string m_nombre;
const int m_id; // Las constantes DEBEN inicializarse en la lista de miembros
public:
// Constructor por defecto usando delegación [C++11]
// Este constructor le pasa valores predeterminados al constructor principal.
Sensor() : Sensor("Sensor_Generico", 0) {}
// Constructor parametrizado con lista de inicialización
// Es mucho más eficiente que asignar valores dentro de las llaves {}.
Sensor(std::string nombre, int id)
: m_nombre(std::move(nombre)), m_id(id)
{
std::cout << "[Sensor] " << m_nombre << " (ID: " << m_id << ") creado\n";
}
// Prohibir la copia del objeto usando = delete
// Esto evita que se dupliquen recursos costosos o únicos.
Sensor(const Sensor&) = delete;
Sensor& operator=(const Sensor&) = delete;
// Destructor de la clase derivada
~Sensor() override {
std::cout << "[Sensor] Destructor de " << m_nombre << "\n";
}
void mostrar() const {
std::cout << " Sensor: " << m_nombre << " ID: " << m_id << "\n";
}
};
int main() {
std::cout << "--- Caso 1: Objeto en el stack ---\n";
{
Sensor s("Termometro", 101);
s.mostrar();
} // Al salir de este bloque, se llama automáticamente a ~Sensor() y ~Base()
std::cout << "\n--- Caso 2: Objeto en el heap (Polimorfismo) ---\n";
// Usamos un puntero de la clase Base para gestionar un Sensor.
Base* ptr = new Sensor("Presion", 202);
ptr->mostrar(); // Error de compilación si mostrar() no fuera virtual (pero es un método normal)
delete ptr; // Gracias al destructor virtual, se ejecuta la cadena completa de destructores.
return 0;
}
En el código anterior, fíjate en cómo funciona la lista de inicialización en Sensor(std::string nombre, int id). No estamos asignando valores dentro del cuerpo { ... }, sino que estamos configurando los miembros m_nombre y m_id antes de que el constructor empiece a ejecutarse. Esto es obligatorio para m_id porque es const; una vez que el cuerpo del constructor comienza, la constante ya ha sido creada y no se puede modificar.
Cuando llamamos a Sensor s("Termometro", 101); en el stack, el compilador reserva el espacio en la pila y luego ejecuta la secuencia de constructores: primero Base() y luego Sensor(...). Al llegar al final de la llave }, el proceso se invierte para la destrucción.
En el caso del heap con Base* ptr = new Sensor(...), la palabra clave virtual en ~Base() es la que hace la magia. Sin ella, al hacer delete ptr, el compilador solo miraría el tipo de puntero (Base*) y ejecutaría únicamente ~Base(), dejando la parte de Sensor (su std::string y su lógica) en un limbo de memoria. Gracias al destructor virtual, se consulta la vtable (tabla de funciones virtuales) para encontrar la implementación correcta del destructor en la clase derivada.
Por último, la delegación de constructores en Sensor() nos permite reutilizar la lógica del constructor principal, evitando duplicar código y asegurando que todos los objetos, incluso los predeterminados, sigan las mismas reglas de inicialización.
El error frecuente
Un error clásico es declarar un destructor en una clase base pero olvidar la palabra clave virtual.
class Base {
public:
~Base() { /* Limpieza básica */ } // Error: NO ES VIRTUAL
};
class Derivada : public Base {
int* m_data;
public:
Derivada() : m_data(new int[100]) {}
~Derivada() { delete[] m_data; } // Este destructor NUNCA se llamará si usas un puntero de Base
};
// ... en el main ...
Base* ptr = new Derivada();
delete ptr; // ERROR: Solo se llama a ~Base(). m_data se fuga (memory leak).
Si compilas con -fsanitize=address (en GCC/Clang), el AddressSanitizer te avisará inmediatamente de esta fuga de memoria al detectar que el objeto Derivada no fue destruido correctamente.
N° 36