Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Memoria Dinámica Avanzada

Estructuras dinámicas y patrones avanzados

Universidad Nacional de Rio Negro - Sede Andina

Introducción

Este apunte explora conceptos avanzados de memoria dinámica en C, construyendo sobre las bases presentadas en Memoria y Punteros. Aquí profundizamos en el manejo de Estructuras que contienen punteros, problemas comunes de gestión de memoria, y técnicas para trabajar con matrices dinámicas.

Punteros a Estructuras

Cuando una estructura (struct) contiene punteros a otros datos, debemos gestionar la memoria en múltiples niveles. Como vimos en El Montón (Heap), cada llamada a malloc reserva memoria en el heap que debe ser liberada explícitamente. Con estructuras anidadas, este principio se aplica recursivamente.

Creación de Estructuras Dinámicas

Para crear una instancia de una estructura que contiene punteros (como char* nombre), se requieren múltiples asignaciones de memoria. Consideremos una estructura persona_t:

typedef struct {
    char *nombre;
    int edad;
} persona_t;

El proceso de creación involucra tres pasos fundamentales:

Paso 1: Asignar la Estructura Contenedora

Primero, reservamos memoria para la estructura en sí:

persona_t *nuevo = malloc(sizeof(persona_t));
if (nuevo == NULL) {
    // Manejar error de asignación
    return NULL;
}

Paso 2: Asignar Miembros Internos

Luego, reservamos memoria para cada puntero dentro de la estructura:

// +1 para el carácter nulo '\0'
nuevo->nombre = malloc(sizeof(char) * (strlen(nombre) + 1));
if (nuevo->nombre == NULL) {
    free(nuevo);  // Liberar lo ya asignado
    return NULL;
}

Paso 3: Copiar Datos

Finalmente, copiamos los datos a la memoria recién asignada:

strcpy(nuevo->nombre, nombre);
nuevo->edad = edad;

Operador Flecha (->)

El operador -> es un atajo sintáctico para acceder a miembros de una estructura a través de un puntero. Como se explica en Punteros, este operador combina la desreferencia y el acceso a miembro en una sola operación.

Equivalencia:

puntero->miembro  ≡  (*puntero).miembro

Ejemplo comparativo:

persona_t *p = /* ... */;

// Usando ->
p->edad = 30;
p->nombre[0] = 'J';

// Equivalente sin ->
(*p).edad = 30;
(*p).nombre[0] = 'J';

La notación con -> es más legible y es la forma idiomática en C para trabajar con punteros a estructuras.

Destrucción de Estructuras Dinámicas

La liberación de memoria debe seguir el orden inverso al de la creación. Este patrón se conoce como “de adentro hacia afuera” o LIFO (Last In, First Out).

Orden Correcto de Liberación

void persona_destruir(persona_t *persona) {
    if (persona == NULL) {
        return;  // Nada que hacer
    }
    
    // 1. Liberar miembros internos primero
    free(persona->nombre);
    
    // 2. Liberar la estructura contenedora
    free(persona);
}

¿Por Qué Este Orden?

Si liberás persona primero, perdés el puntero a persona->nombre. Una vez que free(persona) se ejecuta, acceder a persona->nombre es comportamiento indefinido (ver Dangling Pointer (Puntero Colgante)). Esto resulta en un memory leak porque la memoria de nombre queda asignada pero inaccesible.

Orden correcto vs incorrecto de liberación de memoria en estructuras anidadas.

Figure 1:Orden correcto vs incorrecto de liberación de memoria en estructuras anidadas.

Generalización: Estructuras con Múltiples Punteros

Para estructuras con varios niveles de punteros, aplicá el mismo principio recursivamente:

typedef struct {
    char *nombre;
    char *apellido;
    int *calificaciones;  // Array dinámico
} estudiante_t;

void estudiante_destruir(estudiante_t *est) {
    if (est == NULL) return;
    
    free(est->calificaciones);  // Nivel más profundo primero
    free(est->apellido);
    free(est->nombre);
    free(est);                  // Contenedor al final
}

Problemas Comunes de Memoria Dinámica

Esta sección detalla errores frecuentes en la gestión de memoria dinámica y sus soluciones. Estos problemas se amplían en Errores Comunes y Peligros.

Fragmentación del Heap

La fragmentación externa ocurre cuando la memoria libre se divide en bloques pequeños y no contiguos, aunque la suma total de memoria libre sea suficiente para una solicitud.

Escenario Ilustrativo

Proceso de fragmentación del heap: bloques libres no contiguos impiden asignaciones grandes.

Figure 2:Proceso de fragmentación del heap: bloques libres no contiguos impiden asignaciones grandes.

Problema: Aunque hay 150 KB libres (100 + 50), no podés asignar un bloque contiguo de 120 KB.

Soluciones

Table 1:Estrategias contra Fragmentación

Estrategia

Descripción

Cuándo Usar

Asignación en bloque

Pedir memoria en bloques grandes, subdividir internamente

Arrays redimensionables, pools de objetos

Memory pools

Pre-asignar conjunto de objetos del mismo tamaño

Asignaciones/liberaciones frecuentes del mismo tipo

Compactación

Reorganizar bloques para unir espacios libres

Raramente posible en C (requiere actualizar punteros)

Punteros Colgantes (Dangling Pointers)

Un puntero colgante (dangling pointer) es un puntero que apunta a memoria que ya ha sido liberada con free. Este es uno de los errores más peligrosos en C (ver Dangling Pointer (Puntero Colgante) para más detalles).

Causa

Cuando llamás free(puntero), la memoria se libera pero la variable puntero no cambia. Sigue conteniendo la dirección antigua, que ahora es inválida.

int *datos = malloc(sizeof(int) * 10);
// ... usar datos ...
free(datos);
// En este punto, 'datos' sigue apuntando a la dirección antigua
// pero esa memoria puede estar siendo usada por otra parte del programa

Riesgo: Comportamiento Indefinido

Usar un puntero colgante (leer o escribir) invoca undefined behavior. El programa puede:

free(datos);
datos[0] = 42;  // UNDEFINED BEHAVIOR

Solución: Poner en NULL Después de free

free(puntero);
puntero = NULL;  // Ahora es seguro verificar con if (puntero != NULL)

Ejemplo de Función Defensiva

void datos_liberar(int **ptr) {
    if (ptr == NULL || *ptr == NULL) {
        return;  // Nada que hacer
    }
    
    free(*ptr);
    *ptr = NULL;  // El llamador ve el puntero actualizado
}

// Uso:
int *datos = malloc(sizeof(int) * 10);
datos_liberar(&datos);  // Pasa la dirección del puntero
// Ahora datos == NULL

Liberar Memoria No Dinámica

Intentar liberar memoria que no fue asignada dinámicamente es un error grave que resulta en undefined behavior.

Regla Fundamental

Errores Comunes

1. Liberar variables del stack:

int main() {
    char automatica[] = "hola mundo";  // En el stack
    free(automatica);  // ERROR: undefined behavior
}

Como se explica en Segmentación de la Memoria, las variables automáticas se gestionan automáticamente en el stack. No necesitan (ni deben) ser liberadas manualmente.

2. Liberar literales de cadena:

char *mensaje = "Hola";  // Literal en .rodata (read-only data)
free(mensaje);  // ERROR: undefined behavior

Los literales de cadena residen en el segmento .rodata (ver Segmentación de la Memoria) y son de solo lectura.

3. Liberar variables globales:

int global_arr[100];  // Segmento .bss o .data

void funcion() {
    free(global_arr);  // ERROR: undefined behavior
}

Funciones Adicionales de Gestión de Memoria

Más allá de malloc y free, C proporciona funciones adicionales para manipular memoria dinámica. Estas se detallan completamente en Funciones de Gestión de Memoria (<stdlib.h>).

calloc: Asignación con Inicialización

void *calloc(size_t cantidad, size_t tamaño);

Asigna memoria para un arreglo de cantidad elementos, cada uno de tamaño bytes. Crucialmente, inicializa toda la memoria a cero.

Comparación con malloc:

// Usando malloc
int *arr1 = malloc(10 * sizeof(int));
// arr1[i] contiene basura

// Usando calloc
int *arr2 = calloc(10, sizeof(int));
// arr2[i] == 0 para todo i

realloc: Redimensionar Bloques

void *realloc(void *bloque, size_t nuevo_tamaño);

Cambia el tamaño de un bloque de memoria existente. Esta función es fundamental para implementar arrays redimensionables.

Comportamiento de realloc

Table 2:Casos de realloc

Condición

Comportamiento

Notas

bloque == NULL

Equivalente a malloc(nuevo_tamaño)

Útil para simplificar código

nuevo_tamaño == 0

Equivalente a free(bloque)

Devuelve NULL

nuevo_tamaño > tamaño_original

Expande el bloque

Memoria adicional no inicializada

nuevo_tamaño < tamaño_original

Reduce el bloque

Datos más allá de nuevo_tamaño se pierden

Uso Correcto de realloc

Patrón correcto:

int *temp = realloc(arr, nuevo_tamaño * sizeof(int));
if (temp == NULL) {
    // realloc falló, arr sigue válido
    // Manejar error (liberar arr si es necesario)
    return ERROR;
}
arr = temp;  // Éxito: actualizar puntero

¿Por Qué realloc Puede Mover el Bloque?

Si no hay espacio contiguo para expandir el bloque en su ubicación actual, realloc:

  1. Asigna un nuevo bloque más grande en otra ubicación

  2. Copia los datos del bloque original al nuevo

  3. Libera el bloque original

  4. Retorna la dirección del nuevo bloque

Proceso de realloc cuando debe mover el bloque a una nueva ubicación.

Figure 3:Proceso de realloc cuando debe mover el bloque a una nueva ubicación.

memset: Relleno de Memoria

void *memset(void *destino, int valor, size_t count);

Rellena los primeros count bytes de destino con valor (convertido a unsigned char).

Usos comunes:

// Inicializar array a cero
int arr[100];
memset(arr, 0, sizeof(arr));

// Limpiar buffer sensible
char password[64];
// ... usar password ...
memset(password, 0, sizeof(password));  // Borrar rastros

memcpy: Copia de Memoria

void *memcpy(void *destino, const void *origen, size_t count);

Copia count bytes desde origen a destino. Las regiones no deben solaparse.

Ejemplo:

int src[5] = {1, 2, 3, 4, 5};
int dst[5];
memcpy(dst, src, sizeof(src));
// dst == {1, 2, 3, 4, 5}

Arreglos de Largo Variable (VLA)

Los VLA (Variable Length Arrays) son arreglos cuyo tamaño se determina en tiempo de ejecución, no en compilación.

void funcion(int cantidad) {
    int arreglo[cantidad];  // <-- VLA: tamaño determinado en runtime
}

¿Por Qué Prohibimos VLAs?

1. Asignación en el Stack

Los VLAs se crean en el stack, no en el heap (ver Comparación Stack vs Heap). El stack tiene tamaño limitado (típicamente 1-8 MB).

void procesar(int n) {
    int datos[n];  // VLA en el stack
    
    // Si n es grande (por ejemplo, 1,000,000), esto causa stack overflow
}

2. No Hay Mecanismo de Error

A diferencia de malloc, que retorna NULL si falla, un VLA que excede el stack simplemente crashea el programa:

int *heap_arr = malloc(1000000 * sizeof(int));
if (heap_arr == NULL) {
    // Podemos manejar el error
    fprintf(stderr, "Memoria insuficiente\n");
    return ERROR;
}

// vs

int stack_arr[1000000];  // VLA: ¡CRASH sin posibilidad de recuperación!

3. Problemas de Portabilidad

El límite del stack varía entre plataformas y configuraciones. Código que funciona en una máquina puede crashear en otra.

Alternativa Correcta: Memoria Dinámica

void funcion(int cantidad) {
    int *arreglo = malloc(cantidad * sizeof(int));
    if (arreglo == NULL) {
        // Manejar error
        return;
    }
    
    // Usar arreglo...
    
    free(arreglo);
}

Matrices Dinámicas

Una matriz (arreglo bidimensional) puede implementarse de varias formas en memoria dinámica. Cada enfoque tiene trade-offs en complejidad, eficiencia de memoria y acceso.

Como se explica en El Montón (Heap), la memoria dinámica nos permite crear estructuras de tamaño arbitrario. Las matrices dinámicas extienden este concepto a dos dimensiones.

Enfoque 1: Matriz “Dentada” (Array de Punteros)

Este enfoque crea un arreglo de punteros, donde cada puntero apunta a una fila (otro arreglo). Se llama “dentada” (jagged array) porque cada fila puede tener largo diferente (aunque típicamente usamos filas del mismo tamaño).

Representación de una matriz dentada: array de punteros a arrays.

Figure 4:Representación de una matriz dentada: array de punteros a arrays.

Asignación

int **matriz;
int filas = 3, columnas = 4;

// Paso 1: Array de punteros a filas
matriz = (int **)malloc(filas * sizeof(int *));
if (matriz == NULL) {
    return NULL;
}

// Paso 2: Cada fila
for (int i = 0; i < filas; i++) {
    matriz[i] = (int *)malloc(columnas * sizeof(int));
    if (matriz[i] == NULL) {
        // Error: liberar lo ya asignado
        for (int j = 0; j < i; j++) {
            free(matriz[j]);
        }
        free(matriz);
        return NULL;
    }
}

Acceso

El acceso es natural con la sintaxis estándar de C:

matriz[i][j] = 42;
int valor = matriz[i][j];

Liberación

Siguiendo el principio “de adentro hacia afuera” (Destrucción de Estructuras Dinámicas):

// 1. Liberar cada fila
for (int i = 0; i < filas; i++) {
    free(matriz[i]);
}

// 2. Liberar el array de punteros
free(matriz);

Ventajas y Desventajas

Ventajas:

Desventajas:

Enfoque 2: Bloque Único (Simulación Manual)

Este enfoque asigna toda la matriz como un único bloque contiguo en memoria. Es más eficiente pero requiere calcular índices manualmente.

Matriz almacenada como bloque contiguo: todas las filas consecutivas en memoria.

Figure 5:Matriz almacenada como bloque contiguo: todas las filas consecutivas en memoria.

Asignación

int *matriz;
int filas = 3, columnas = 4;

matriz = (int *)malloc(filas * columnas * sizeof(int));
if (matriz == NULL) {
    return NULL;
}

Acceso Manual

No podés usar matriz[i][j] directamente porque matriz es int *, no int **. Debés calcular el índice lineal:

// Acceso: fila i, columna j
int valor = matriz[i * columnas + j];

// Asignación
matriz[i * columnas + j] = 42;

Explicación del cálculo:

Mapeo entre la representación lógica 2D y la memoria lineal contigua.

Figure 6:Mapeo entre la representación lógica 2D y la memoria lineal contigua.

Liberación

Solo una llamada a free:

free(matriz);

Función de Acceso Helper

Para mejorar la legibilidad, podés crear una función:

static inline int matriz_get(int *matriz, int fila, int col, int num_cols) {
    return matriz[fila * num_cols + col];
}

static inline void matriz_set(int *matriz, int fila, int col, int num_cols, int valor) {
    matriz[fila * num_cols + col] = valor;
}

// Uso:
matriz_set(matriz, i, j, columnas, 42);
int val = matriz_get(matriz, i, j, columnas);

Ventajas y Desventajas

Ventajas:

Desventajas:

Enfoque 3: Bloque Único con Cast Avanzado

Este enfoque combina lo mejor de ambos mundos: memoria contigua del Enfoque 2 con la sintaxis natural del Enfoque 1, mediante un cast especial del puntero.

Asignación con Puntero a Array

int filas = 3, columnas = 4;

// Puntero a un array de 'columnas' enteros
int (*matriz)[columnas] = (int (*)[columnas])malloc(
    sizeof(int) * columnas * filas
);

if (matriz == NULL) {
    return NULL;
}

Acceso Natural

Ahora podés usar la sintaxis estándar:

matriz[i][j] = 42;
int valor = matriz[i][j];

¿Cómo funciona?

Liberación

Solo un free:

free(matriz);

Comparación de Declaraciones

// Enfoque 1: Array de punteros
int **matriz1;           // Puntero a puntero a int

// Enfoque 2: Puntero simple
int *matriz2;            // Puntero a int

// Enfoque 3: Puntero a array
int (*matriz3)[columnas];  // Puntero a array de columnas ints

Limitación: Tamaño de Columnas en Compile-Time (C99+)

Para C89, usarías:

#define COLUMNAS 4
int (*matriz)[COLUMNAS] = malloc(sizeof(int) * COLUMNAS * filas);

Ventajas y Desventajas

Ventajas:

Desventajas:

Comparación de Enfoques

Table 3:Comparación de Implementaciones de Matrices

Aspecto

Enfoque 1 (Dentada)

Enfoque 2 (Bloque Manual)

Enfoque 3 (Bloque + Cast)

Sintaxis de acceso

matriz[i][j]

matriz[i*cols + j] ⚠️

matriz[i][j]

Asignaciones malloc

filas + 1 ⚠️

1

1

Overhead de memoria

filas * sizeof(int*) ⚠️

0

0

Localidad de cache

Baja ⚠️

Alta ✅

Alta ✅

Fragmentación

Alta ⚠️

Ninguna ✅

Ninguna ✅

Filas de tamaño variable

Sí ✅

No ⚠️

No ⚠️

Complejidad código

Media

Media

Alta ⚠️


Conceptos Clave

Este apunte explora patrones avanzados de memoria dinámica en C, construyendo sobre los fundamentos de Introducción: El Mapa de Memoria de un Programa y Punteros.

Conexión con el Siguiente Tema

Dominando la gestión avanzada de memoria dinámica, tenés las herramientas para implementar estructuras de datos complejas: listas enlazadas, árboles, grafos, hash tables. Pero construir estas estructuras correctamente requiere algo más que conocimiento técnico de punteros.

El apunte TAD, Pilas y Colas introduce el concepto de Tipos Abstractos de Datos (TADs):

Un TAD bien diseñado permite cambiar completamente la implementación interna (por ejemplo, de matriz dentada a bloque único) sin afectar al código cliente. Esta separación de concerns es fundamental para escribir software mantenible y escalable.

Los punteros y la memoria dinámica son las herramientas de bajo nivel; los TADs son los principios arquitecturales que guían su uso profesional.

Pregunta puente: Una lista enlazada y un array dinámico implementan la misma interfaz abstracta (secuencia de elementos). ¿Cómo decidir cuál usar? ¿Cómo diseñar la interfaz para que sea independiente de la implementación? El análisis de TADs responde estas preguntas.

Referencias y Lecturas Complementarias

Textos Fundamentales sobre Memoria Dinámica

Gestión de Memoria y Debugging

Matrices y Estructuras Multidimensionales

Optimización y Performance

Patrones de Diseño con Memoria Dinámica

Herramientas de Análisis

References
  1. Kernighan, B. W., & Ritchie, D. M. (2014). C Programming Language, 2nd Edition.
  2. King, K. N. (2008). C Programming: A Modern Approach (2nd ed.). W. W. Norton & Company.
  3. Gustedt, J. (2019). Modern C. Manning Publications. https://modernc.gforge.inria.fr/
  4. Seacord, R. C. (2013). Secure Coding in C and C++ (2nd ed.). Addison-Wesley.
  5. van der Linden, P. (1994). Expert C Programming: Deep C Secrets. Prentice Hall.
  6. Bryant, R. E., & O’Hallaron, D. R. (2015). Computer Systems: A Programmer’s Perspective (3rd ed.). Pearson.
  7. Warren, H. S. (2012). Hacker’s Delight (2nd ed.). Addison-Wesley.
  8. Hanson, D. R. (1996). C Interfaces and Implementations: Techniques for Creating Reusable Software. Addison-Wesley.
  9. Plauger, P. J. (1992). The Standard C Library. Prentice Hall.