Gestión de memoria dinámica con new y delete

Cuando declaras una variable normal dentro de una función, como int x = 5;, esa variable vive en la pila (stack). La pila es una zona de memoria muy rápida, pero automática: cuando la función termina, la variable se destruye sola. Sin embargo, a veces necesitas que un objeto sobreviva más allá de la función que lo creó, o necesitas reservar mucha más memoria de la que la pila puede ofrecer. Para eso usamos la memoria dinámica (o heap), que es un almacén de memoria mucho más grande pero cuya gestión es manual.

Para usar el heap, empleamos el operador new. Este operador hace dos cosas: primero, busca un bloque de memoria lo suficientemente grande para tu tipo de dato y, segundo, llama al constructor del objeto para inicializarlo. El resultado es un puntero (una variable que contiene la dirección de memoria donde reside el objeto). Cuando ya no necesites ese objeto, debes usar delete. El operador delete hace lo opuesto: primero llama al destructor del objeto (para que este limpie sus propios recursos internos) y luego libera la memoria para que otros procesos puedan usarla.

Si quieres crear una colección de objetos, usas new[] para asignar un array de elementos. Es vital que, si usaste corchetes para crear el array, uses delete[] para destruirlo. Si no lo haces, el programa solo destruirá el primer elemento y dejará el resto en un limbo de memoria. Si la memoria del sistema se agota, new lanzará una excepción de tipo std::bad_alloc. Si prefieres evitar excepciones y simplemente recibir un puntero nulo (nullptr) cuando no hay memoria, puedes usar la variante new (std::nothrow).

Debes usar new únicamente cuando el ciclo de vida del objeto deba ser controlado manualmente por ti. Si usas new para un objeto individual, usa delete. Si usas new[] para un array, usa delete[]. Si intentas usar malloc (de C) con objetos de C++, no se llamarán los constructores y tu programa fallará estrepitosamente.

#include <iostream>
#include <string>

// Una clase sencilla para observar el ciclo de vida del objeto
class Sensor {
public:
    std::string nombre;

    // El constructor se llama cuando usamos 'new'
    Sensor(std::string n) : nombre(n) {
        std::cout << "[Constructor] Sensor " << nombre << " creado en el heap.\n";
    }

    // El destructor se llama cuando usamos 'delete'
    ~Sensor() {
        std::cout << "[Destructor] Sensor " << nombre << " destruido.\n";
    }
};

int main() {
    // 1. Asignación de un objeto único en el heap
    // 'sensor' es un puntero que guarda la dirección del objeto
    Sensor* sensor = new Sensor("Temperatura");
    
    std::cout << "Trabajando con el sensor: " << sensor->nombre << "\n";

    // Liberar la memoria del objeto individual
    delete sensor; 

    // 2. Asignación de un array de objetos en el heap
    // Usamos new[] para reservar espacio para 3 sensores
    Sensor* sensores = new Sensor[3]{
        Sensor("Humedad"),
        Sensor("Presión"),
        Sensor("Luz"}
    };

    // Acceder a un elemento del array mediante el puntero
    std::cout << "Segundo sensor en el array: " << sensores[1].nombre << "\n";

    // IMPORTANTE: Para arrays, es obligatorio usar delete[]
    delete[] sensores;

    // En C++, borrar un puntero que ya es nullptr es seguro y no hace nada
    Sensor* puntero_nulo = nullptr;
    delete puntero_nulo; 

    return 0;
}

En el código anterior, sensor es un puntero que almacena la dirección de memoria de un objeto Sensor creado en el heap. Cuando llamamos a delete sensor, el programa salta a esa dirección, ejecuta el código del destructor ~Sensor() para limpiar el std::string interno, y luego marca esa memoria como disponible.

Cuando ejecutamos new Sensor[3], el compilador reserva un bloque de memoria contiguo lo suficientemente grande para tres objetos Sensor. Es fundamental usar delete[] sensores porque el compilador necesita ese símbolo [] para saber cuántos destructores debe ejecutar; de lo contrario, solo llamaría al destructor de sensores[0] y dejaría el resto de la memoria ocupada (una fuga de memoria). Finalmente, notarás que delete puntero_nulo es una operación segura que no provoca errores, lo cual es una garantía del estándar de C++.

El error frecuente

Un error clásico que causa comportamientos impredecibles es el “puntero colgante” (dangling pointer). Esto ocurre cuando liberas la memoria, pero intentas seguir usando el puntero.

int* ptr = new int(42);
delete ptr; // La memoria es devuelta al sistema

// ERROR: ptr sigue teniendo la dirección, pero el objeto ya no existe.
// Esto es Comportamiento Indefinido (Undefined Behavior).
std::cout << *ptr << std::endl; 

Otro error crítico es el desajuste entre new[] y delete. Si creas un array con new[] pero intentas liberarlo con delete (sin corchetes), el comportamiento es indefinido. Esto suele pasar porque el mecanismo interno que usa el compilador para gestionar arrays es distinto al de objetos individuales. Este error es muy difícil de detectar porque puede que el programa parezca funcionar bien durante la fase de desarrollo, pero fallará aleatoriamente o corromperá la memoria cuando se ejecute en producción. Herramientas como AddressSanitizer (usando el flag -fsanitize=address en gcc/clang) detectarán esto inmediatamente al ejecutar el programa.

Para evitar estos problemas en el desarrollo moderno, prefiere siempre el uso de contenedores estándar como std::vector o gestores de memoria inteligente como std::unique_ptr y std::shared_ptr.

30

Dejar un comentario

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

Scroll al inicio