Memoria dinámica en C — práctica con un acta de notas que se ajusta, un vector que crece al llegar alumnos y una tabla de notas en el heap (y el realloc que casi me deja sin datos)

Segunda sesión con memoria dinámica. El vector de notas ya funcionaba con malloc, pero llegaron alumnos nuevos y escribí, muy contento, notas = realloc(notas, nuevo_n * sizeof(double));. Funcionó. Funcionó diez veces seguidas. Hasta que un compañero me preguntó: «¿y si realloc devuelve NULL?». Me quedé mirando la línea. Si fallaba, acababa de pisar mi único puntero a las notas con NULL: datos perdidos y un bloque reservado que ya nadie podía liberar. Desde entonces, realloc siempre va con un puntero temporal, y esa idea va a aparecer en los tres programas de hoy.

En la teoría de memoria dinámica en C viste malloc, calloc, realloc y free por separado. Aquí los juntamos en tres programas de nivel medio con el ejemplo de siempre, las notas de una clase, y con datos que se parecen a los de verdad: una nota mal tecleada (11.5), alumnos que llegan sin avisar y una tabla con varias pruebas por alumno. Los tres programas usan datos fijos en lugar de scanf, para que puedas comparar tu salida con la mía; cambiarlos por lecturas del teclado es uno de los «Pruébalo tú».

Antes de empezar

Conviene tener claro:

  • Que malloc(n * sizeof *v) reserva n casillas y que hay que comprobar NULL.
  • Que realloc puede mover el bloque y que su resultado se guarda en un puntero temporal.
  • Que cada bloque se libera una sola vez con free y que después se pone el puntero a NULL.
  • Cómo pasar un puntero a una función para que la función modifique la variable de quien llama (lo viste en funciones en C).

Si alguno te suena dudoso, repasa la teoría antes de seguir.

Compila cada programa con gcc -Wall -std=c11 programa.c -o programa y ejecútalo con ./programa. Mientras lo modificas, añade -g -fsanitize=address: si te sales del bloque o te olvidas un free, te lo dirá al momento.

Programa 1: el acta de notas que se ajusta

El profe teclea primero cuántas notas va a meter (8) y después las notas. Una de ellas, 11.5, es un error de tecleo. El programa reserva sitio para 8, guarda solo las válidas (entre 0 y 10), encoge el vector al número real de notas y calcula la media, los aprobados, la máxima, la mínima y qué notas superan la media. Lo que lo complica es que el tamaño final (7) no se sabe hasta haber leído todo.

La idea

  1. Leer el número de notas n y reservar n casillas con malloc.
  2. Recorrer la entrada con un índice i, pero guardar con otro índice k, que solo avanza cuando la nota es válida. Así las notas válidas quedan juntas al principio del vector, sin huecos.
  3. Si k < n, sobran casillas: realloc para encoger a k, con puntero temporal.
  4. Calcular las estadísticas sobre las k notas y liberar.

Antes de programar, piensa en qué índice usas para leer y cuál para escribir. Si usas el mismo i para las dos cosas, la nota descartada deja un hueco con basura en mitad del vector.

El código

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

/* Lo que "teclea" el profe: primero cuántas notas y luego las notas (una vale 11.5 por error) */
const double ENTRADA[] = {8, 6.5, 4.0, 9.5, 11.5, 5.0, 7.0, 3.0, 8.5};

/* Copia en v las notas válidas (0..10) y devuelve cuántas ha guardado */
int leer_notas(double *v, int n, const double *entrada) {
    int k = 0;                              /* k = casillas ocupadas de verdad */
    for (int i = 0; i < n; i++) {
        double x = entrada[i];
        if (x < 0 || x > 10) {              /* nota imposible: avisamos y no la guardamos */
            printf("Descartada la nota %.1f\n", x);
            continue;
        }
        v[k++] = x;                         /* guarda en v[k] y DESPUÉS suma 1 a k */
    }
    return k;
}

double media(const double *v, int n) {
    double suma = 0;
    for (int i = 0; i < n; i++) suma += v[i];
    return suma / n;
}

int main(void) {
    int n = (int)ENTRADA[0];                /* 8: el tamaño solo se conoce al ejecutar */
    double *notas = malloc(n * sizeof *notas);
    if (notas == NULL) { printf("Sin memoria\n"); return 1; }

    int k = leer_notas(notas, n, ENTRADA + 1);   /* ENTRADA + 1: saltamos el 8 */

    if (k < n) {                            /* sobran casillas: encogemos el vector */
        double *tmp = realloc(notas, k * sizeof *notas);
        if (tmp != NULL) {                  /* si fallara, el vector grande sigue sirviendo */
            printf("Vector ajustado de %d a %d casillas (%zu -> %zu bytes)\n",
                   n, k, n * sizeof *notas, k * sizeof *notas);
            notas = tmp;
        }
    }

    double m = media(notas, k);
    int aprobados = 0;
    double max = notas[0], min = notas[0];
    for (int i = 0; i < k; i++) {
        if (notas[i] >= 5) aprobados++;     /* >= porque un 5.0 es aprobado */
        if (notas[i] > max) max = notas[i];
        if (notas[i] < min) min = notas[i];
    }
    printf("Notas validas: %d | media %.2f | aprobados %d\n", k, m, aprobados);
    printf("Maxima %.1f | minima %.1f\n", max, min);
    printf("Por encima de la media:");
    for (int i = 0; i < k; i++)
        if (notas[i] > m) printf(" %.1f", notas[i]);
    printf("\n");

    free(notas);
    notas = NULL;
    return 0;
}

El corazón del programa es leer_notas. Vamos a verla aparte:

/* Copia en v las notas válidas (0..10) y devuelve cuántas ha guardado */
int leer_notas(double *v, int n, const double *entrada) {
    int k = 0;                              /* k = casillas ocupadas de verdad */
    for (int i = 0; i < n; i++) {
        double x = entrada[i];
        if (x < 0 || x > 10) {              /* nota imposible: avisamos y no la guardamos */
            printf("Descartada la nota %.1f\n", x);
            continue;
        }
        v[k++] = x;                         /* guarda en v[k] y DESPUÉS suma 1 a k */
    }
    return k;
}

i recorre la entrada; k cuenta las casillas ocupadas de verdad. La expresión v[k++] = x hace dos cosas en este orden: guarda x en v[k] y después suma 1 a k. Esta es la traza con los datos reales:

Vuelta (i)entrada[i]¿Válida?Se guarda enk después
06.5Sív[0]1
14.0Sív[1]2
29.5Sív[2]3
311.5No (> 10)—3
45.0Sív[3]4
57.0Sív[4]5
63.0Sív[5]6
78.5Sív[6]7
leer_notas copia solo las válidasArriba, las 8 notas de la entrada; el 11.5 está descartado. Abajo, el vector de 8 casillas: las 7 notas válidas se copian en las casillas 0 a 6 y la casilla 7 sobra, así que realloc la recorta.ENTRADA + 1 (lo que teclea el profe)6.5i=04.0i=19.5i=211.5i=35.0i=47.0i=53.0i=68.5i=7descartada6.5k=04.0k=19.5k=25.0k=37.0k=43.0k=58.5k=6sobrak=7notas: malloc de 8 casillask = 7 → realloc(notas, 7 * sizeof *notas): 64 → 56 bytes
i recorre la entrada y k solo avanza cuando la nota es válida: por eso el 5.0 cae en la casilla 3.

En el dibujo se ve por qué el 5.0 cae en la casilla 3 y no en la 4: el 11.5 no ocupa sitio, así que todo lo que viene detrás se corre una casilla a la izquierda. Al final queda una casilla sobrante, la 7, y de eso se encarga el realloc:

if (k < n) {                            /* sobran casillas: encogemos el vector */
    double *tmp = realloc(notas, k * sizeof *notas);
    if (tmp != NULL) {                  /* si fallara, el vector grande sigue sirviendo */
        printf("Vector ajustado de %d a %d casillas (%zu -> %zu bytes)\n",
               n, k, n * sizeof *notas, k * sizeof *notas);
        notas = tmp;
    }
}

Encoger es la forma más segura de usar realloc, pero aun así se usa el temporal: si fallara, el vector de 8 seguiría siendo perfectamente válido (las 7 notas están en las 7 primeras casillas), así que el programa sigue sin hacer nada más.

Salida

Descartada la nota 11.5
Vector ajustado de 8 a 7 casillas (64 -> 56 bytes)
Notas validas: 7 | media 6.21 | aprobados 5
Maxima 9.5 | minima 3.0
Por encima de la media: 6.5 9.5 7.0 8.5

Comprobémosla a mano. Las 7 notas válidas suman 6.5 + 4.0 + 9.5 + 5.0 + 7.0 + 3.0 + 8.5 = 43.5, y 43.5 / 7 = 6.214…, que se imprime como 6.21. Aprobados (≥ 5): 6.5, 9.5, 5.0, 7.0 y 8.5, que son 5 (el 5.0 cuenta gracias al >=). Los bytes del ajuste: 8 × 8 = 64 y 7 × 8 = 56. Y por encima de 6.21 quedan 6.5, 9.5, 7.0 y 8.5, en el orden en el que llegaron.

Preguntas que te puedes hacer

**¿Por qué ENTRADA + 1 al llamar a leer_notas?** Porque ENTRADA[0] es el 8 (el número de notas), no una nota. ENTRADA + 1 es un puntero a la segunda casilla, así que dentro de la función entrada[0] ya es 6.5. Es aritmética de punteros pura.

¿Por qué reservar 8 si al final son 7? Porque no sabes cuántas serán válidas hasta leerlas todas. Reservar el máximo posible y encoger después es un patrón muy común.

**¿Por qué max y min empiezan en notas[0] y no en 0?** Si min empezara en 0, ninguna nota sería menor y la mínima saldría 0.0. Empezar con un dato real evita inventarse un valor inicial.

¿Y si las 8 notas fueran inválidas? k valdría 0 y media dividiría entre 0, y realloc(notas, 0) tampoco es buena idea. En un programa real habría que comprobar k == 0 antes de seguir: es una de las variaciones de abajo.

**¿Hace falta el notas = NULL final si el programa termina justo después?** Estrictamente no, pero es un hábito barato que evita sustos cuando el código crece y alguien añade líneas después del free.

Usar i también para guardar (v[i] = x). La casilla 3 se quedaría sin escribir (con basura) y el vector tendría un hueco en mitad de los datos.

Pruébalo tú

  • Cambia los datos fijos por scanf: primero lee n y después las n notas. Comprueba también que n sea mayor que 0.
  • Añade el caso k == 0: si no hay ninguna nota válida, imprime «No hay notas válidas», libera y termina sin calcular la media.

Programa 2: un vector que crece cuando llegan alumnos

Ahora no sabemos ni siquiera cuántas notas habrá: llegan una a una hasta que aparece un -1. Empezamos con sitio para 2 y, cada vez que el vector se llena, duplicamos su capacidad con realloc. Al final ajustamos al tamaño exacto. Es exactamente lo que hacen por dentro las listas de Python o el ArrayList de Java.

La idea

Hay que llevar dos números distintos:

  • n: cuántas notas hay guardadas.
  • cap: cuántas casillas hay reservadas.

Cuando n == cap, no cabe la siguiente: se duplica cap con realloc. ¿Por qué duplicar y no sumar una casilla cada vez? Porque cada realloc puede copiar el vector entero a otro sitio. Si creciera de uno en uno, con 9 notas harían falta 8 realloc; duplicando, solo 3.

La función anadir tiene que poder cambiar notas, n y cap de main, así que los recibe por puntero.

Antes de programar, piensa en por qué anadir recibe double **v y no double *v. Si realloc mueve el bloque, la dirección nueva tiene que llegar a la variable notas de main.

El código

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

/* Las notas llegan una a una y no sabemos cuántas serán: -1 marca el final */
const double ENTRADA[] = {7.5, 6.0, 8.0, 4.5, 9.0, 5.5, 6.5, 3.5, 10.0, -1};

/* Añade x al final. Si no cabe, DUPLICA la capacidad. Devuelve 1 si va bien y 0 si no.
   Recibe double **v porque realloc puede mover el bloque y main tiene que enterarse. */
int anadir(double **v, int *n, int *cap, double x) {
    if (*n == *cap) {                                  /* lleno: toca crecer */
        int nueva = *cap * 2;
        double *tmp = realloc(*v, nueva * sizeof **v);
        if (tmp == NULL) return 0;                     /* *v sigue intacto */
        printf("  [crece] capacidad %d -> %d (%zu -> %zu bytes)\n",
               *cap, nueva, *cap * sizeof **v, nueva * sizeof **v);
        *v = tmp;
        *cap = nueva;
    }
    (*v)[*n] = x;        /* paréntesis: primero *v (el vector), luego [*n] */
    (*n)++;              /* (*n)++ suma al contador; *n++ movería el puntero */
    return 1;
}

int main(void) {
    int n = 0, cap = 2;                          /* empezamos con sitio para 2 */
    double *notas = malloc(cap * sizeof *notas);
    if (notas == NULL) return 1;

    for (int i = 0; ENTRADA[i] != -1; i++) {
        if (!anadir(&notas, &n, &cap, ENTRADA[i])) {
            printf("Sin memoria: me quedo con %d notas\n", n);
            break;
        }
        printf("Nota %.1f guardada (n=%d, cap=%d)\n", ENTRADA[i], n, cap);
    }

    double *tmp = realloc(notas, n * sizeof *notas);   /* al final, sobra sitio: ajustamos */
    if (tmp != NULL) {
        printf("Ajuste final: capacidad %d -> %d\n", cap, n);
        notas = tmp;
        cap = n;
    }

    double suma = 0;
    for (int i = 0; i < n; i++) suma += notas[i];
    printf("%d notas, media %.2f\n", n, suma / n);

    free(notas);
    return 0;
}

Zoom a anadir, que es donde está todo lo difícil:

/* Añade x al final. Si no cabe, DUPLICA la capacidad. Devuelve 1 si va bien y 0 si no.
   Recibe double **v porque realloc puede mover el bloque y main tiene que enterarse. */
int anadir(double **v, int *n, int *cap, double x) {
    if (*n == *cap) {                                  /* lleno: toca crecer */
        int nueva = *cap * 2;
        double *tmp = realloc(*v, nueva * sizeof **v);
        if (tmp == NULL) return 0;                     /* *v sigue intacto */
        printf("  [crece] capacidad %d -> %d (%zu -> %zu bytes)\n",
               *cap, nueva, *cap * sizeof **v, nueva * sizeof **v);
        *v = tmp;
        *cap = nueva;
    }
    (*v)[*n] = x;        /* paréntesis: primero *v (el vector), luego [*n] */
    (*n)++;              /* (*n)++ suma al contador; *n++ movería el puntero */
    return 1;
}

Qué tipo tienes en cada paso de las expresiones con dos asteriscos:

ExpresiónTipoQué es
vdouble **La dirección de la variable notas de main
*vdouble *El valor de notas: la dirección del bloque
(*v)[*n]doubleLa casilla número n del bloque
**vdoubleLa primera casilla; solo se usa en sizeof **v (8 bytes)
*nintEl contador de main

Los paréntesis de (*v)[*n] no son decoración: [] va antes que *, así que *v[*n] significaría «la casilla *n de v«, que es otra cosa y se sale de la memoria. Igual con (*n)++: sin paréntesis, *n++ movería el puntero n en lugar de sumar al contador.

Salida

Nota 7.5 guardada (n=1, cap=2)
Nota 6.0 guardada (n=2, cap=2)
  [crece] capacidad 2 -> 4 (16 -> 32 bytes)
Nota 8.0 guardada (n=3, cap=4)
Nota 4.5 guardada (n=4, cap=4)
  [crece] capacidad 4 -> 8 (32 -> 64 bytes)
Nota 9.0 guardada (n=5, cap=8)
Nota 5.5 guardada (n=6, cap=8)
Nota 6.5 guardada (n=7, cap=8)
Nota 3.5 guardada (n=8, cap=8)
  [crece] capacidad 8 -> 16 (64 -> 128 bytes)
Nota 10.0 guardada (n=9, cap=16)
Ajuste final: capacidad 16 -> 9
9 notas, media 6.72

Esta es la traza de las veces que cambia la capacidad:

Nota que llegan antescap antes¿Lleno?cap después
7.502No2
6.012No2
8.022Sí4
9.044Sí8
10.088Sí16
(fin)916—9 (ajuste)
Un vector que duplica su capacidadCinco filas: capacidad 2 con 2 notas, capacidad 4 con 4, capacidad 8 con 8, capacidad 16 con 9 notas y 7 huecos, y el ajuste final a 9 casillas. Las notas 8.0, 9.0 y 10.0, que obligaron a crecer, están resaltadas.cap = 27.56.0cap = 47.56.08.04.5cap = 87.56.08.04.59.05.56.53.5cap = 167.56.08.04.59.05.56.53.510.0ajuste7.56.08.04.59.05.56.53.510.0coral = la nota que obligó a crecer · discontinuo = sitio reservado sin usar
Con 9 notas solo hacen falta 3 realloc para crecer y 1 para ajustar, en lugar de uno por nota.

En el dibujo se ve cada estado del vector: en coral, la nota que no cabía y obligó a crecer; en discontinuo, el sitio reservado sin usar. Con 16 casillas y 9 notas sobran 7 × 8 = 56 bytes, y por eso al final se ajusta a 9. Comprobación de la media: 7.5 + 6.0 + 8.0 + 4.5 + 9.0 + 5.5 + 6.5 + 3.5 + 10.0 = 60.5, y 60.5 / 9 = 6.72.

Preguntas que te puedes hacer

**¿Por qué la línea [crece] sale antes que «Nota 8.0 guardada»?** Porque anadir crece primero y guarda después: la nota 8.0 se guarda ya en el vector de 4 casillas.

¿No es un desperdicio reservar 16 para 9? Durante un rato sí, como mucho el doble de lo necesario. A cambio ahorras muchas copias. Y el ajuste final lo arregla.

**¿Por qué anadir devuelve 0 o 1 en lugar de terminar el programa?** Porque decidir qué hacer si no hay memoria es cosa de quien llama. main decide quedarse con las notas que ya tiene; otro programa podría guardar en un archivo y salir.

**¿Qué pasa con las notas si el realloc de crecimiento falla?** Nada malo: *v no se toca y n tampoco, así que main sigue teniendo un vector válido con todas las notas anteriores.

Confundir n con cap. Si calculas la media dividiendo entre cap (16) en lugar de n (9), la media sale 3.78 y además sumas casillas que contienen basura.

Pruébalo tú

  • Cambia la estrategia de crecimiento a «sumar 2 casillas» y cuenta cuántas líneas [crece] salen con las mismas 9 notas.
  • Lee las notas con scanf hasta que el usuario escriba -1 y prueba a meter 20 notas: ¿hasta qué capacidad llega?

Programa 3: una tabla de notas en el heap

El profe tiene varias pruebas por alumno (prácticas, parcial y final, con pesos 30 %, 30 % y 40 %) y el número de alumnos lo sabe al ejecutar. Queremos una tabla alumnos × pruebas en memoria dinámica que se use como tabla[i][j], calcular la nota final de cada uno y liberarlo todo sin dejarnos nada.

La idea

Una tabla dinámica en C es un vector de punteros, donde cada puntero apunta a una fila:

  1. malloc de un vector de alumnos punteros a double (double **).
  2. Un calloc por fila, con las 3 notas de cada alumno, empezando a 0.0.
  3. Si falla la reserva de una fila a mitad, liberar las filas ya reservadas y el vector.
  4. Al terminar, liberar de dentro afuera: primero cada fila y después el vector de punteros.

Antes de programar, piensa en el orden de los free. Si liberas primero el vector de punteros, ya no tienes forma de llegar a las filas: se quedan como fugas.

El código

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

#define PRUEBAS 3                             /* practicas, parcial y final */
const double PESO[PRUEBAS] = {0.3, 0.3, 0.4};
const char *NOMBRE[] = {"Ana", "Bruno", "Carla", "Diego"};
const double DATOS[][PRUEBAS] = {{8.0, 6.5, 7.0}, {5.0, 4.0, 3.5},
                                 {9.5, 8.0, 9.0}, {6.0, 7.5, 4.5}};

/* Libera las 'filas' primeras filas y después el vector de punteros (en ese orden) */
void liberar_tabla(double **t, int filas) {
    for (int i = 0; i < filas; i++)
        free(t[i]);                           /* primero cada fila... */
    free(t);                                  /* ...y al final el vector que las apuntaba */
}

/* Tabla filas x cols en el heap: un vector de punteros y una fila por alumno */
double **crear_tabla(int filas, int cols) {
    double **t = malloc(filas * sizeof *t);   /* sizeof *t = sizeof(double *) */
    if (t == NULL) return NULL;
    for (int i = 0; i < filas; i++) {
        t[i] = calloc(cols, sizeof **t);      /* cada fila empieza a 0.0 */
        if (t[i] == NULL) {                   /* si falla la fila i, liberamos las i anteriores */
            liberar_tabla(t, i);
            return NULL;
        }
    }
    return t;
}

int main(void) {
    int alumnos = 4;                          /* el numero de alumnos llega al ejecutar */
    double **tabla = crear_tabla(alumnos, PRUEBAS);
    if (tabla == NULL) { printf("Sin memoria\n"); return 1; }
    printf("Reservado: %d punteros (%zu bytes) + %d filas de %zu bytes\n",
           alumnos, alumnos * sizeof(double *), alumnos, PRUEBAS * sizeof(double));

    for (int i = 0; i < alumnos; i++)
        for (int j = 0; j < PRUEBAS; j++)
            tabla[i][j] = DATOS[i][j];        /* tabla[i] es una fila; [j], su casilla */

    printf("Alumno   Pract.  Parcial  Final  | Nota\n");
    for (int i = 0; i < alumnos; i++) {
        double nota = 0;
        for (int j = 0; j < PRUEBAS; j++)
            nota += tabla[i][j] * PESO[j];
        printf("%-8s %5.1f %8.1f %6.1f  | %.2f %s\n", NOMBRE[i], tabla[i][0], tabla[i][1],
               tabla[i][2], nota, nota >= 5 ? "aprobado" : "suspenso");
    }

    liberar_tabla(tabla, alumnos);
    tabla = NULL;
    return 0;
}

Las dos funciones que hacen el trabajo de memoria. Primero, la que libera:

/* Libera las 'filas' primeras filas y después el vector de punteros (en ese orden) */
void liberar_tabla(double **t, int filas) {
    for (int i = 0; i < filas; i++)
        free(t[i]);                           /* primero cada fila... */
    free(t);                                  /* ...y al final el vector que las apuntaba */
}

Recibe cuántas filas liberar, no siempre todas. Eso permite reutilizarla dentro de crear_tabla cuando una fila falla a mitad:

/* Tabla filas x cols en el heap: un vector de punteros y una fila por alumno */
double **crear_tabla(int filas, int cols) {
    double **t = malloc(filas * sizeof *t);   /* sizeof *t = sizeof(double *) */
    if (t == NULL) return NULL;
    for (int i = 0; i < filas; i++) {
        t[i] = calloc(cols, sizeof **t);      /* cada fila empieza a 0.0 */
        if (t[i] == NULL) {                   /* si falla la fila i, liberamos las i anteriores */
            liberar_tabla(t, i);
            return NULL;
        }
    }
    return t;
}

Si falla la fila i, las filas 0 a i - 1 sí existen: por eso se llama a liberar_tabla(t, i). Así una función que falla no deja basura reservada. Qué tipo tienes en cada nivel:

ExpresiónTipoQué es
tabladouble **Dirección del vector de punteros
tabla[i]double *Dirección de la fila del alumno i
tabla[i][j]doubleLa nota j del alumno i
sizeof *tsizeof(double *)8 bytes por puntero
sizeof **tsizeof(double)8 bytes por nota
Una tabla dinámica: punteros a filasLa variable tabla apunta a un vector de 4 punteros; cada puntero apunta a una fila de 3 notas de un alumno. Los números indican el orden de los free: primero las 4 filas y por último el vector de punteros.vector de punterosuna fila por alumno (calloc de 3)double **tablatabla[0]8.06.57.0Ana1freetabla[1]5.04.03.5Bruno2freetabla[2]9.58.09.0Carla3freetabla[3]6.07.54.5Diego4free5free(tabla)
Se libera de dentro afuera: primero cada fila (1 a 4) y al final el vector que las apuntaba (5).

El dibujo muestra la estructura completa con los datos del programa y los números del orden en que se liberan: las cuatro filas (1 a 4) y el vector de punteros el último (5).

Salida

Reservado: 4 punteros (32 bytes) + 4 filas de 24 bytes
Alumno   Pract.  Parcial  Final  | Nota
Ana        8.0      6.5    7.0  | 7.15 aprobado
Bruno      5.0      4.0    3.5  | 4.10 suspenso
Carla      9.5      8.0    9.0  | 8.85 aprobado
Diego      6.0      7.5    4.5  | 5.85 aprobado

Comprobemos a mano la nota de Ana: 8.0 × 0.3 + 6.5 × 0.3 + 7.0 × 0.4 = 2.40 + 1.95 + 2.80 = 7.15. La de Bruno: 1.50 + 1.20 + 1.40 = 4.10, suspenso. Y la memoria: 4 punteros × 8 = 32 bytes más 4 filas de 3 × 8 = 24 bytes, 128 bytes en total. Las columnas no quedan perfectamente alineadas bajo la cabecera: eso es cosa del formato de printf, no de la memoria.

Preguntas que te puedes hacer

**¿Por qué no double tabla[alumnos][3]?** Desde C99 existen los vectores de longitud variable (VLA), pero viven en la pila: no puedes devolverlos desde una función y, si son grandes, desbordan la pila. Con el heap no tienes ninguno de los dos problemas.

**¿Por qué calloc en las filas y no malloc?** Porque así una fila recién creada tiene ceros en lugar de basura. Si un alumno no se presentara a una prueba, su casilla valdría 0.0, que tiene sentido.

¿Las filas están seguidas en memoria? No necesariamente. Cada calloc puede dar un bloque en un sitio distinto del heap. Por eso tabla[i][j] funciona: primero va a buscar la fila por su puntero y después la casilla.

**¿Por qué el vector de punteros usa malloc y no calloc?** Porque lo rellenamos entero justo después, fila a fila. Si una fila falla, ya sabemos cuántas son válidas (i) y no leemos las demás.

**¿Qué pasa si llamo a liberar_tabla(tabla, alumnos) dos veces?** Doble free de cada fila: el programa aborta. Por eso después ponemos tabla = NULL.

Liberar solo tabla con un free(tabla). Las cuatro filas siguen reservadas y ya no hay ningún puntero que llegue a ellas: 4 fugas de 24 bytes.

Pruébalo tú

  • Añade una columna más (una cuarta prueba con su peso) cambiando solo PRUEBAS, PESO y DATOS. ¿Qué parte del código de memoria has tenido que tocar?
  • Añade una función double *notas_finales(double **t, int filas) que devuelva un vector dinámico con la nota final de cada alumno. Acuérdate de liberarlo en main.

Errores típicos en esta práctica

  • Un solo índice para leer y escribir. Si filtras datos, el índice de escritura (k) tiene que ser distinto del de lectura (i).
  • **realloc sin temporal.** Aunque estés encogiendo, guarda el resultado en tmp y comprueba NULL.
  • **Confundir n con cap.** Uno cuenta datos; el otro, casillas reservadas. Las medias y los bucles van con n.
  • **Olvidar los paréntesis con **.** (*v)[*n] y (*n)++; sin ellos, el programa accede a otra memoria.
  • Liberar la tabla de fuera adentro. Primero las filas, después el vector de punteros.
  • No liberar lo reservado cuando algo falla a mitad. Cada camino de salida tiene que dejar la memoria limpia.

Míralo paso a paso en Python Tutor

Abre Python Tutor, elige C (gcc) y pega esta versión corta del programa 2 (5 notas, sin funciones). Pulsa Visualize Execution y avanza con Next.

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

int main(void) {
    int n = 0, cap = 2;
    double *notas = malloc(cap * sizeof(double));
    if (notas == NULL) return 1;
    double llegan[] = {7.5, 6.0, 8.0, 4.5, 9.0};

    for (int i = 0; i < 5; i++) {
        if (n == cap) {
            double *tmp = realloc(notas, 2 * cap * sizeof(double));
            if (tmp == NULL) break;
            notas = tmp;
            cap = 2 * cap;
        }
        notas[n] = llegan[i];
        n++;
    }
    printf("n=%d cap=%d\n", n, cap);
    free(notas);
    return 0;
}

Qué observar paso a paso:

  1. Después del malloc, el bloque del heap tiene 2 casillas y cap vale 2. Las dos primeras vueltas lo rellenan sin crecer.
  2. En la tercera vuelta, n == cap: se ejecuta el realloc y en el heap aparece un bloque de 4 casillas con 7.5 y 6.0 ya copiados. Mira a dónde apuntan tmp y notas.
  3. En la quinta vuelta vuelve a llenarse y el bloque pasa a 8 casillas. Fíjate en que las casillas 5 a 7 quedan sin valor: reservadas, pero sin usar.
  4. Antes del free, compara n (5) y cap (8): son las dos cifras que tienes que llevar siempre por separado.

Resumen

  • Para filtrar datos en un vector, usa un índice de lectura y otro de escritura, y encoge al final con realloc.
  • Un vector dinámico que crece lleva dos números, n y cap, y duplica la capacidad cuando se llena.
  • Una función que tiene que cambiar un puntero de quien llama recibe un puntero a puntero (double **).
  • realloc siempre con temporal, también al encoger.
  • Una tabla dinámica es un vector de punteros a filas; se reserva de fuera adentro y se libera de dentro afuera.
  • Si una reserva falla a mitad, libera lo que ya habías reservado antes de devolver NULL.

Checklist: ¿lo has practicado?

  • ¿Has ejecutado los tres programas y comprobado las salidas?
  • ¿Sabes explicar por qué el 5.0 acaba en la casilla 3 del acta?
  • ¿Podrías decir en qué vueltas crece el vector del programa 2 sin mirar la salida?
  • ¿Entiendes por qué anadir recibe double ** y no double *?
  • ¿Sabes en qué orden se liberan las filas y el vector de punteros, y por qué?
  • ¿Has intentado los «Pruébalo tú» sin mirar la teoría?

Si has respondido que sí a casi todo, estás listo para los ejercicios de memoria dinámica en C, donde ya no te doy el código hecho. ¡Nos vemos allí!

Siguiente paso: ejerciciosMemoria dinámica en C — ejercicios para subir notas, filtrar aprobados y mantener un vector ordenado que crece (y el doble free que me tumbó el acta)

¿Te ha servido este artículo? Así puedes apoyar SergioLearns →

Sigue aprendiendo

Deja una respuesta

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