Gestión de memoria manual y el puente con C++

Cuando trabajas en C++, lo natural es confiar en new y delete (o mejor aún, en smart pointers), pero para entender cómo se mueve la memoria a bajo nivel, es imprescindible entender las funciones de la familia malloc, heredadas de C y disponibles en <cstdlib>.

std::malloc reserva un bloque de n bytes en el heap sin tener la menor idea de qué tipo de objeto va a ocupar ese espacio. A diferencia de new, malloc no inicializa la memoria (devuelve basura) y, lo más crítico, no llama a constructores. Por su parte, std::free simplemente marca esa región de memoria como disponible para el sistema operativo, sin ejecutar ningún destructor. Si intentas usar std::realloc para redimensionar un bloque, la función intentará expandir el bloque actual o moverlo a una nueva ubicación, devolviendo un puntero al nuevo inicio si es necesario.

Estas funciones son herramientas de bajo nivel que operan sobre bytes puros, ignorando por completo el sistema de tipos de C++. Esto las hace extremadamente rápidas y útiles en escenarios específicos: cuando necesitas interoperabilidad con librerías escritas en C, cuando estás implementando tu propio allocator personalizado para un motor de juegos, o cuando necesitas usar placement new para construir objetos en una zona de memoria prealocada (como un memory pool). Sin embargo, si mezclas estas funciones con los operadores de C++, entrarás en el terreno del comportamiento indefinido (UB). Si usas malloc para reservar espacio y luego intentas liberar ese espacio con delete, el programa puede colapsar porque delete intenta invocar un destructor y utiliza mecanismos de liberación de memoria distintos a los de free.

#include <iostream>
#include <cstdlib> // Para malloc, free, realloc
#include <new>     // Para placement new
#include <string>

// Un objeto que gestiona su propia memoria interna (RAII)
struct BufferItem {
    std::string name;

    BufferItem(std::string n) : name(std::move(n)) {
        std::cout << "[Constructor] " << name << " creado\n";
    }

    ~BufferItem() {
        std::cout << "[Destructor] " << name << destruido\n";
    }
};

void demo_memoria() {
    const size_t num_elementos = 2;
    
    // 1. Alocación bruta (raw allocation)
    // malloc solo nos da bytes, no objetos.
    void* raw_ptr = std::malloc(num_elementos * sizeof(BufferItem));
    if (!raw_ptr) return;

    std::cout << "Memoria bruta alocada en: " << raw_ptr << "\n";

    // 2. Placement new
    // Construimos objetos C++ dentro del espacio de memoria ya alocado.
    // Esto es vital en sistemas de alta performance para evitar fragmentación.
    BufferItem* items = new (&raw_ptr) BufferItem("Objeto 1");
    // Nota: Para el segundo elemento, necesitaríamos un offset manual
    // pero para simplificar el ejemplo, usemos un solo objeto complejo.
    
    // El primer objeto está en 'raw_ptr'.
    // Si quisiéramos un segundo objeto, deberíamos calcular:
    // BufferItem* second = new (static_cast<char*>(raw_ptr) + sizeof(BufferItem)) BufferItem("Objeto 2");

    // 3. Uso del objeto
    std::cout << "Usando: " << items->name << "\n";

    // 4. Limpieza manual (Crucial)
    // Como usamos placement new, 'free' no llamará al destructor.
    // Debemos llamar al destructor manualmente antes de liberar la memoria.
    items->~BufferItem(); 

    // Ahora que el objeto ha "muerto" lógicamente, liberamos la memoria física.
    std::free(raw_ptr);
    std::cout << "Memoria liberada correctamente.\n";
}

int main() {
    demo_memoria();
    return 0;
}

Para que el código anterior funcione correctamente, la secuencia es sagrada: alocar $\rightarrow$ construir $\rightarrow$ usar $\rightarrow$ destruir $\rightarrow$ liberar. En el ejemplo, std::malloc nos otorga el espacio necesario, pero es mediante new (&raw_ptr) BufferItem(...) (conocido como placement new) como transformamos esos bytes en un objeto vivo con un estado inicial válido.

Fíjate en la destrucción: como std::malloc no sabe qué es un BufferItem, si simplemente llamáramos a std::free(raw_ptr), el objeto name (que es un std::string) nunca ejecutaría su propio destructor. Esto causaría una fuga de memoria (memory leak) de la cadena interna del string. La llamada explícita items->~BufferItem() es lo que permite que la lógica de RAII de los miembros del objeto se ejecute antes de que la memoria base sea devuelta al sistema.

Respecto a la alineación, std::malloc garantiza que el puntero devuelto esté alineado para cualquier tipo fundamental. Sin embargo, si necesitas alineaciones específicas para tipos con requisitos especiales (como tipos de instrucciones SIMD), desde C++17 deberías usar std::aligned_alloc en lugar de malloc.

El error frecuente

El error más peligroso y difícil de depurar es la mezcla de estilos de gestión de memoria. Si alocas un objeto con new e intentas liberarlo con free, o si alocas con malloc e intentas liberarlo con delete, el resultado es Undefined Behavior.

// ERROR: Mezclar new con free
Resource* r = new Resource("Error");
std::free(r); // UB: Se saltó el destructor y el método de liberación es incompatible.

// ERROR: Mezclar malloc con delete
Resource* r2 = static_cast<Resource*>(std::malloc(sizeof(Resource)));
delete r2; // UB: El destructor se llama, pero delete intentará usar el operador de deallocación 
           // estándar, lo cual puede corromper el heap si malloc usa un allocator distinto.

Este último error es especialmente insidioso porque, en algunas implementaciones de MSVC o GCC, el programa podría no fallar inmediatamente, pero corromperás las estructuras internas de gestión de memoria del proceso, provocando un crash aleatorio mucho más adelante en la ejecución. Herramientas como AddressSanitizer (ASan) con la bandera -fsanitize=address son imprescindibles para detectar estos desajustes de alineación y desajustes entre el tipo de alocación y el de liberación.

31

Dejar un comentario

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

Scroll al inicio