Gestión de la memoria dinámica con malloc, calloc y realloc

Cuando escribes un programa, el sistema operativo le asigna un bloque de memoria para que trabaje. Sin embargo, la mayoría de las variables que declaras (como int x; o char buffer[100];) tienen un tamaño fijo que se decide antes de que el programa empiece a ejecutarse. ¿Qué ocurre cuando necesitas un arreglo cuyo tamaño depende de un archivo que el usuario va a leer, o de una conexión de red que no sabes cuánto pesará? Aquí es donde entra la memoria dinámica.

La memoria dinámica te permite solicitar bytes de memoria al sistema operativo mientras el programa ya está corriendo. Esta memoria reside en una región llamada heap (o montículo), un área de la memoria mucho más grande y flexible que la stack (pila), donde las variables locales viven. Para gestionar este espacio, usamos funciones específicas que nos piden un número determinado de bytes. Si la petición tiene éxito, estas funciones nos devuelven un puntero (una dirección de memoria) que nos indica dónde empieza ese bloque. Si el sistema se ha quedado sin memoria o la petición es imposible, nos devuelven NULL.

Para usar esto de forma efectiva, debemos entender qué estamos haciendo. Básicamente, estamos gestionando manualmente la vida útil de nuestros datos. Si pides memoria y no la devuelves, la memoria se queda “atrapada” para tu proceso, lo que conocemos como fuga de memoria (memory leak). Si intentas usar una dirección que ya has devuelto, tu programa intentará acceder a un lugar que ya no le pertenece, provocando un error de segmentación (segmentation fault) o un comportamiento errático e impredecible.

Para cuando necesites flexibilidad en el tamaño de tus estructuras durante la ejecución, la gestión manual es la única vía.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t cantidad_inicial = 3;
    size_t tamaño_elemento = sizeof(int);

    // 1. Uso de calloc: reserva memoria y la inicializa a cero.
    // Ideal para arreglos donde quieres que los valores empiecen en 0.
    int *datos = calloc(cantidad_inicial, tamaño_elemento);

    if (datos == NULL) {
        fprintf(stderr, "Error: No se pudo asignar la memoria inicial.\n");
        return 1;
    }

    // Llenamos el arreglo con valores básicos
    for (size_t i = 0; i < cantidad_inicial; i++) {
        datos[i] = (int)i + 10; 
    }

    printf("Datos originales: ");
    for (size_t i = 0; i < cantidad_inicial; i++) {
        printf("%d ", datos[i]);
    }
    printf("\n");

    // 2. Uso de realloc: redimensionar el bloque existente.
    // Queremos expandir el arreglo para que quepan 5 elementos.
    size_t nueva_cantidad = 5;
    // IMPORTANTE: Usamos un puntero temporal para evitar perder la referencia
    // original si realloc falla y devuelve NULL.
    int *temp = realloc(datos, nueva_cantidad * tamaño_elemento);

    if (temp == NULL) {
        fprintf(stderr, "Error: No se pudo redimensionar la memoria.\n");
        // Si realloc falla, 'datos' sigue siendo válido, por lo que debemos liberarlo.
        free(datos);
        return 1;
    }
    
    // Si el redimensionamiento tuvo éxito, actualizamos nuestro puntero principal.
    datos = temp;

    // Añadimos los nuevos elementos
    datos[3] = 40;
    datos[4] = 50;

    printf("Datos expandidos: ");
    for (size_t i = 0; i < nueva_cantidad; i++) {
        printf("%d ", datos[i]);
    }
    printf("\n");

    // 3. Uso de free: liberar la memoria para que el sistema la reutilice.
    // Es vital llamar a free para cada bloque obtenido con malloc/calloc/realloc.
    free(datos);
    datos = NULL; // Buena práctica: evitar punteros colgantes.

    return 0;
}

Desglose del código

En la primera parte, utilizamos calloc(cantidad_inicial, tamaño_elemento). A diferencia de malloc, que solo reserva los bytes pero deja el contenido como sea (basura de memoria), calloc se asegura de que cada bit de ese espacio sea cero. Esto es fundamental para evitar comportamientos extraños si intentas leer el arreglo antes de llenarlo. Observa que usamos sizeof(int) para calcular cuántos bytes necesitamos; nunca asumas que un int siempre mide lo mismo, deja que el compilador te dé el tamaño exacto.

La parte más crítica es el uso de realloc. Cuando queremos que datos crezca para albergar 5 elementos en lugar de 3, llamamos a realloc. Fíjate que no asignamos el resultado directamente a datos. En su lugar, usamos int *temp. Si realloc no puede encontrar espacio suficiente, devolverá NULL, pero la memoria original de datos seguirá existiendo. Si hubiéramos hecho datos = realloc(datos, ...), al recibir un NULL, habríamos perdido la dirección de la memoria original, haciendo imposible liberarla y provocando una fuga de memoria inmediata.

Finalmente, el ciclo de vida de la memoria se cierra con free(datos). Una vez que llamas a free, esa dirección de memoria ya no es tuya. Por eso, asignar datos = NULL después de liberarlo es una técnica común para evitar el error de intentar liberar la misma memoria dos veces o acceder a ella por accidente.

El error frecuente

Un error clásico en programadores que están aprendiendo es gestionar mal el retorno de realloc.

// ERROR FATAL
// Si realloc falla, 'puntero' se vuelve NULL y perdemos la dirección original.
// La memoria original se queda "flotando" en el heap sin posibilidad de ser liberada.
int *puntero = malloc(100 * sizeof(int));
// ... uso de puntero ...
puntero = realloc(puntero, 200 * sizeof(int)); // Si esto falla, ¡adiós memoria!

Este error es sutil porque el programa no suele fallar en la línea del realloc, sino mucho más tarde, cuando el sistema se queda sin memoria o cuando intentas liberar un puntero que ya es NULL. Las herramientas como Valgrind o AddressSanitizer detectarán esto inmediatamente al reportar una “fuga de memoria” (memory leak) al finalizar la ejecución.

66

Dejar un comentario

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

Scroll al inicio