← Programación en C para Sistemas Operativos Bloque 7 · Memoria dinámica · Tema 30 de 42

Tema 30

Estructuras de datos dinámicas

Con malloc y free en la mano, y con las herramientas del tema anterior para comprobar el trabajo, llega el momento de construir algo. Este tema cierra el bloque 7 con las dos estructuras que casi todo programa de sistemas necesita: el arreglo que crece y la lista enlazada. Las dos guardan una cantidad de elementos que no se conoce de antemano, y se construyen con las mismas piezas, pero se comportan de manera muy distinta en velocidad y en memoria, y esa diferencia se mide. Veremos cómo se escribe cada una sin errores, cómo se destruye, qué ocurre con la caché del procesador, cómo se hacen las listas que usa el núcleo Linux, una cola con la que se simula un planificador de procesos, y la cuestión que atraviesa todo el bloque: quién es dueño de cada bloque de memoria.

1. Elegir una estructura

Una estructura de datos dinámica guarda una cantidad de elementos que se conoce recién al ejecutar el programa, y permite agregar y quitar. Las dos de este tema se distinguen por cómo guardan los elementos:

La elección se hace por las operaciones que más se usan:

OperaciónArreglo dinámicoLista simple (con puntero a la cola)Lista doble circular
Acceder al elemento iInmediato, v[i].Recorrer i nodos.Recorrer i nodos.
Agregar al finalInmediato, salvo cuando hay que crecer (promedio constante).Inmediato.Inmediato.
Agregar al principioMover todos los elementos.Inmediato.Inmediato.
Quitar el últimoInmediato.Recorrer toda la lista.Inmediato.
Insertar o quitar en el medio, si ya se tiene la posiciónMover los elementos de atrás.Inmediato (se necesita el anterior).Inmediato.
Buscar un valorRecorrer.Recorrer.Recorrer.
Memoria por elementoSolo el elemento (más la capacidad que sobra).El elemento, un puntero y la sobrecarga de malloc.El elemento, dos punteros y la sobrecarga.
¿Se mueven los elementos al crecer?Sí: los punteros a elementos quedan colgantes.No: cada nodo se queda donde nació.No.
Velocidad real al recorrerMuy buena (secuencial en memoria).Peor, y mucho peor si los nodos están dispersos (sección 5).Igual que la simple.

La regla práctica: el arreglo es la opción por omisión, porque es más simple, usa menos memoria y es más rápido; la lista gana cuando se insertan y quitan elementos del medio u de los extremos con mucha frecuencia, o cuando importa que los elementos no se muevan, porque otras partes del programa guardan punteros a ellos. Las dos secciones siguientes las construyen completas, con todo el cuidado con la memoria que pedía el tema 29.

2. El arreglo que crece

El tema 28 anunció que este tema construiría un arreglo dinámico completo. Hace falta llevar tres datos juntos, que se agrupan en una estructura: el puntero al bloque, la cantidad de elementos en uso, y la capacidad, cuántos caben sin pedir más memoria. Las funciones que lo manejan reciben la estructura por puntero, y ninguna toca los campos desde afuera:

vector.c
#define _DEFAULT_SOURCE                   /* reallocarray */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct vector {
    int *datos;
    size_t cantidad;                      /* elementos en uso */
    size_t capacidad;                     /* elementos que caben sin pedir más */
};

static void vector_iniciar(struct vector *v)
{
    v->datos = NULL;
    v->cantidad = 0;
    v->capacidad = 0;
}

static void vector_destruir(struct vector *v)
{
    free(v->datos);
    vector_iniciar(v);
}

/* asegura lugar para 'n' elementos en total; 0 si lo logró, -1 si no */
static int vector_reservar(struct vector *v, size_t n)
{
    if (n <= v->capacidad)
        return 0;
    size_t nueva = v->capacidad ? v->capacidad : 4;

    while (nueva < n) {
        if (nueva > SIZE_MAX / 2)
            return -1;                    /* duplicar se desbordaría */
        nueva *= 2;
    }
    int *tmp = reallocarray(v->datos, nueva, sizeof *tmp);
    if (tmp == NULL)
        return -1;                        /* v sigue intacto */
    v->datos = tmp;
    v->capacidad = nueva;
    return 0;
}

static int vector_agregar(struct vector *v, int x)
{
    if (vector_reservar(v, v->cantidad + 1) != 0)
        return -1;
    v->datos[v->cantidad++] = x;
    return 0;
}

static int vector_insertar(struct vector *v, size_t i, int x)
{
    if (i > v->cantidad || vector_reservar(v, v->cantidad + 1) != 0)
        return -1;
    memmove(&v->datos[i + 1], &v->datos[i], (v->cantidad - i) * sizeof v->datos[0]);
    v->datos[i] = x;
    v->cantidad++;
    return 0;
}

static int vector_quitar(struct vector *v, size_t i)
{
    if (i >= v->cantidad)
        return -1;
    memmove(&v->datos[i], &v->datos[i + 1], (v->cantidad - i - 1) * sizeof v->datos[0]);
    v->cantidad--;
    return 0;
}

/* devuelve a la biblioteca lo que sobra */
static void vector_ajustar(struct vector *v)
{
    if (v->cantidad == v->capacidad)
        return;
    if (v->cantidad == 0) {
        vector_destruir(v);
        return;
    }
    int *tmp = realloc(v->datos, v->cantidad * sizeof *tmp);
    if (tmp != NULL) {                    /* si falla, el bloque más grande sigue siendo válido */
        v->datos = tmp;
        v->capacidad = v->cantidad;
    }
}

static void mostrar(const char *que, const struct vector *v)
{
    printf("%-22s cantidad %2zu, capacidad %2zu:", que, v->cantidad, v->capacidad);
    for (size_t i = 0; i < v->cantidad; i++)
        printf(" %d", v->datos[i]);
    printf("\n");
}

int main(void)
{
    struct vector v;

    vector_iniciar(&v);
    size_t anterior = 0;

    for (int x = 1; x <= 10; x++) {
        if (vector_agregar(&v, x * x) != 0) {
            fprintf(stderr, "sin memoria\n");
            vector_destruir(&v);
            return 1;
        }
        if (v.capacidad != anterior) {
            printf("al agregar el elemento %2d la capacidad pasó de %2zu a %2zu\n", x, anterior, v.capacidad);
            anterior = v.capacidad;
        }
    }
    mostrar("después de agregar 10", &v);
    vector_insertar(&v, 0, -1);
    mostrar("insertar -1 al inicio", &v);
    vector_quitar(&v, 5);
    mostrar("quitar el índice 5", &v);
    vector_ajustar(&v);
    mostrar("ajustar", &v);
    vector_destruir(&v);
    mostrar("destruido", &v);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o vector vector.c && ./vector
al agregar el elemento  1 la capacidad pasó de  0 a  4
al agregar el elemento  5 la capacidad pasó de  4 a  8
al agregar el elemento  9 la capacidad pasó de  8 a 16
después de agregar 10 cantidad 10, capacidad 16: 1 4 9 16 25 36 49 64 81 100
insertar -1 al inicio  cantidad 11, capacidad 16: -1 1 4 9 16 25 36 49 64 81 100
quitar el índice 5    cantidad 10, capacidad 16: -1 1 4 9 16 36 49 64 81 100
ajustar                cantidad 10, capacidad 10: -1 1 4 9 16 36 49 64 81 100
destruido              cantidad  0, capacidad  0:
$ valgrind --leak-check=full ./vector 2>&1 | grep -E 'total heap|All heap|ERROR SUMMARY'
  total heap usage: 5 allocs, 5 frees, 4,248 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Las decisiones de diseño merecen explicación:

La destrucción es una sola llamada a free: el vector tiene un bloque y es su dueño. Valgrind lo confirma: cinco reservas (las tres ampliaciones, la reducción y la que hace el propio stdio) y cinco liberaciones, cero errores.

3. La lista enlazada

Un nodo de una lista simple contiene el dato y un puntero al nodo siguiente; el último apunta a NULL. La lista misma se representa con una estructura que guarda la cabeza, el primer nodo, y conviene guardar también la cola, el último, para poder agregar al final sin recorrer:

Una lista simplemente enlazada de cuatro nodos. La estructura lista tiene dos punteros: cabeza, que apunta al primer nodo con el valor 1, y cola, que apunta al último nodo con el valor 4. Cada nodo tiene dos campos, valor y siguiente; siguiente apunta al nodo que sigue, y el del último nodo vale NULL cabeza cola 1 sig 2 sig 3 sig 4 NULL Cada nodo es un bloque de malloc distinto. Los punteros son lo único que los une.
La estructura lista guarda el primero y el último; cada nodo, el dato y el siguiente.
lista.c
#include <stdio.h>
#include <stdlib.h>

struct nodo {
    int valor;
    struct nodo *siguiente;
};

struct lista {
    struct nodo *cabeza;
    struct nodo *cola;                    /* el último nodo: agregar al final sin recorrer */
    size_t cantidad;
};

static void lista_iniciar(struct lista *l)
{
    l->cabeza = NULL;
    l->cola = NULL;
    l->cantidad = 0;
}

static struct nodo *nodo_nuevo(int valor)
{
    struct nodo *n = malloc(sizeof *n);

    if (n != NULL) {
        n->valor = valor;
        n->siguiente = NULL;
    }
    return n;
}

static int lista_agregar_inicio(struct lista *l, int valor)
{
    struct nodo *n = nodo_nuevo(valor);

    if (n == NULL)
        return -1;
    n->siguiente = l->cabeza;
    l->cabeza = n;
    if (l->cola == NULL)                  /* era la primera */
        l->cola = n;
    l->cantidad++;
    return 0;
}

static int lista_agregar_final(struct lista *l, int valor)
{
    struct nodo *n = nodo_nuevo(valor);

    if (n == NULL)
        return -1;
    if (l->cola == NULL)
        l->cabeza = n;
    else
        l->cola->siguiente = n;
    l->cola = n;
    l->cantidad++;
    return 0;
}

static struct nodo *lista_buscar(const struct lista *l, int valor)
{
    for (struct nodo *n = l->cabeza; n != NULL; n = n->siguiente)
        if (n->valor == valor)
            return n;
    return NULL;
}

/* quita el primer nodo con ese valor; 0 si lo encontró, -1 si no */
static int lista_quitar(struct lista *l, int valor)
{
    struct nodo *previo = NULL;
    struct nodo **enlace = &l->cabeza;    /* la dirección del puntero que apunta al nodo actual */

    while (*enlace != NULL && (*enlace)->valor != valor) {
        previo = *enlace;
        enlace = &previo->siguiente;
    }
    if (*enlace == NULL)
        return -1;
    struct nodo *borrado = *enlace;

    *enlace = borrado->siguiente;         /* sirve igual para la cabeza y para el medio */
    if (borrado == l->cola)
        l->cola = previo;
    free(borrado);
    l->cantidad--;
    return 0;
}

static void lista_invertir(struct lista *l)
{
    struct nodo *anterior = NULL;
    struct nodo *actual = l->cabeza;

    l->cola = l->cabeza;
    while (actual != NULL) {
        struct nodo *siguiente = actual->siguiente;

        actual->siguiente = anterior;
        anterior = actual;
        actual = siguiente;
    }
    l->cabeza = anterior;
}

static void lista_destruir(struct lista *l)
{
    struct nodo *n = l->cabeza;

    while (n != NULL) {
        struct nodo *siguiente = n->siguiente;   /* guardarlo ANTES de liberar */

        free(n);
        n = siguiente;
    }
    lista_iniciar(l);
}

static void mostrar(const char *que, const struct lista *l)
{
    printf("%-26s", que);
    for (const struct nodo *n = l->cabeza; n != NULL; n = n->siguiente)
        printf("%d -> ", n->valor);
    printf("NULL   (cantidad %zu, cola %s)\n", l->cantidad,
           l->cola ? (l->cola->siguiente == NULL ? "ok" : "MAL") : "NULL");
}

int main(void)
{
    struct lista l;

    lista_iniciar(&l);
    for (int x = 3; x <= 5; x++)
        lista_agregar_final(&l, x);
    lista_agregar_inicio(&l, 2);
    lista_agregar_inicio(&l, 1);
    mostrar("1..5:", &l);

    printf("buscar 4: %s, buscar 9: %s\n",
           lista_buscar(&l, 4) ? "está" : "no está", lista_buscar(&l, 9) ? "está" : "no está");
    lista_quitar(&l, 3);
    mostrar("quitar 3 (medio):", &l);
    lista_quitar(&l, 1);
    mostrar("quitar 1 (cabeza):", &l);
    lista_quitar(&l, 5);
    mostrar("quitar 5 (cola):", &l);
    printf("quitar 9: %d\n", lista_quitar(&l, 9));
    lista_invertir(&l);
    mostrar("invertida:", &l);
    lista_agregar_final(&l, 7);
    mostrar("agregar 7 al final:", &l);
    lista_destruir(&l);
    mostrar("destruida:", &l);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o lista lista.c && ./lista
1..5:                     1 -> 2 -> 3 -> 4 -> 5 -> NULL   (cantidad 5, cola ok)
buscar 4: está, buscar 9: no está
quitar 3 (medio):         1 -> 2 -> 4 -> 5 -> NULL   (cantidad 4, cola ok)
quitar 1 (cabeza):        2 -> 4 -> 5 -> NULL   (cantidad 3, cola ok)
quitar 5 (cola):          2 -> 4 -> NULL   (cantidad 2, cola ok)
quitar 9: -1
invertida:                4 -> 2 -> NULL   (cantidad 2, cola ok)
agregar 7 al final:       4 -> 2 -> 7 -> NULL   (cantidad 3, cola ok)
destruida:                NULL   (cantidad 0, cola NULL)
$ valgrind --leak-check=full ./lista 2>&1 | grep -E 'total heap|All heap|ERROR SUMMARY'
  total heap usage: 7 allocs, 7 frees, 4,192 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Las operaciones de una lista tienen siempre los mismos casos especiales, los extremos, y se pueden ver una por una:

Quitar un nodo es la operación donde casi todos se equivocan, porque obliga a cambiar el puntero que apunta al nodo, y ese puntero es distinto según dónde esté: l->cabeza si es el primero, o el siguiente del nodo anterior. La solución habitual es separar dos casos con un if. Una solución mejor aprovecha el puntero a puntero del tema 19:

Quitar el nodo 3 de la lista 2, 3, 4 con un puntero a puntero. Arriba, antes: enlace apunta al campo siguiente del nodo 2, que a su vez apunta al nodo 3, y borrado es el nodo 3. Abajo, después de la asignación *enlace igual a borrado->siguiente: el campo siguiente del nodo 2 apunta ahora directamente al nodo 4, y el nodo 3, dibujado con línea punteada, queda aislado y se libera con free antes 2 sig 3 sig 4 NULL enlace = &previo->siguiente borrado = *enlace después de *enlace = borrado->siguiente 2 sig 3 · free 4 NULL
La variable enlace guarda la dirección del puntero que hay que cambiar, no la del nodo. Con *enlace se lee y se escribe ese puntero.

En lista_quitar, enlace es un struct nodo **: arranca valiendo &l->cabeza, y en cada paso pasa a valer la dirección del campo siguiente del nodo que se acaba de pasar. *enlace es entonces «el puntero que apunta al nodo actual», sea cual sea, y la operación *enlace = borrado->siguiente lo saltea. No hay ningún caso especial para la cabeza: es la misma línea que para el medio. El único trabajo adicional es el de la cola, que si se quita se actualiza al nodo anterior, que el programa va guardando en previo.

Por último, lista_destruir tiene una línea que parece de más y es la más importante: siguiente = n->siguiente antes de free(n). Después de liberar un nodo ya no se puede leer su campo siguiente. La sección siguiente muestra qué pasa si se invierte el orden.

4. Destruir una lista sin errores

Liberar una lista es la operación donde se cometen dos errores clásicos. El primero es el que acabamos de ver, que se escribe con naturalidad:

liberar_mal.c · el bucle de liberación
    for (struct nodo *n = cabeza; n != NULL; n = n->siguiente)
        free(n);                          /* n->siguiente se lee DESPUÉS de free(n) */
terminal
$ gcc -std=c17 -Wall -Wextra -g -o liberar_mal liberar_mal.c
liberar_mal.c: In function ‘main’:
liberar_mal.c:22:48: warning: pointer ‘n’ used after ‘free’ [-Wuse-after-free]
   22 |     for (struct nodo *n = cabeza; n != NULL; n = n->siguiente)
      |                                              ~~^~~~~~~~~~~~~~
liberar_mal.c:23:9: note: call to ‘free’ here
   23 |         free(n);                          /* n->siguiente se lee DESPUÉS de free(n) */
      |         ^~~~~~~
$ ./liberar_mal
free(): invalid pointer
Aborted
$ ./liberar_mal_asan
==542==ERROR: AddressSanitizer: heap-use-after-free on address 0x7b8b704e0058 at pc 0x5b05aa48e4ab …
READ of size 8 at 0x7b8b704e0058 thread T0
    #0 0x5b05aa48e4aa in main /home/ana/c/tema30/liberar_mal.c:22
    …
0x7b8b704e0058 is located 8 bytes inside of 16-byte region [0x7b8b704e0050,0x7b8b704e0060)
freed by thread T0 here:
    #0 0x7f6b7212a3ff in free …
    #1 0x5b05aa48e439 in main /home/ana/c/tema30/liberar_mal.c:23

gcc avisó con -Wuse-after-free, y el programa aborta con free(): invalid pointer: el campo siguiente de un nodo liberado ya no contiene el puntero original, sino los datos que el administrador escribió en esos bytes (tema 28), y el siguiente free recibe un valor sin sentido. El mensaje no menciona la lista, ni la lectura después de liberar. ASan lo explica: una lectura de 8 bytes, a 8 bytes del comienzo de un bloque de 16, que la línea 23 acababa de liberar. Es el campo siguiente del nodo.

El segundo error es sutil, y aparece con listas largas: liberar con una función recursiva, que parece elegante:

recursiva.c
/* liberar con recursión: cada nodo es un nivel de la pila */
static void liberar_recursivo(struct nodo *n)
{
    if (n == NULL)
        return;
    liberar_recursivo(n->siguiente);
    free(n);
}

int main(int argc, char *argv[])
{
    long cuantos = argc > 1 ? strtol(argv[1], NULL, 10) : 1000000;
    struct nodo *cabeza = NULL;

    for (long i = 0; i < cuantos; i++) {
        struct nodo *n = malloc(sizeof *n);

        if (n == NULL)
            return 1;
        n->valor = (int) i;
        n->siguiente = cabeza;
        cabeza = n;
    }
    printf("lista de %ld nodos construida\n", cuantos);
    liberar_recursivo(cabeza);
    printf("liberada\n");
    return 0;
}
terminal
$ ./recursiva 100000
lista de 100000 nodos construida
liberada
$ ./recursiva 300000
Segmentation fault
$ ./recursiva
Segmentation fault
$ ./recursiva_asan
==553==ERROR: AddressSanitizer: stack-overflow on address 0x7ffdc7325ff8 (pc 0x5e6cb5fb4335 …)
    #0 0x5e6cb5fb4335 in liberar_recursivo /home/ana/c/tema30/recursiva.c:11
    #1 0x5e6cb5fb43c4 in liberar_recursivo /home/ana/c/tema30/recursiva.c:14
    #2 0x5e6cb5fb43c4 in liberar_recursivo /home/ana/c/tema30/recursiva.c:14
    …

Con 100 000 nodos funciona; con 300 000, o con el millón por omisión, el programa termina con Segmentation fault. Cada nodo de la lista es un nivel de recursión, con su marco de pila (tema 12), y la pila tiene 8 MiB, como vimos en el tema 27: con marcos de 32 bytes, el límite está alrededor de los 260 000 niveles, que es lo que ocurre. Compilar con optimización no lo evita, porque el free viene después de la llamada recursiva, de modo que no es una llamada de cola. La conclusión es una regla de diseño: una lista se recorre y se destruye con un bucle, no con recursión; su largo lo decide quien usa el programa, y la pila no es un recurso para gastar a razón de un marco por elemento. ASan lo identifica sin ambigüedad: stack-overflow, con la misma función repetida cientos de miles de veces.

5. Lo que cuesta cada una

La tabla de la sección 1 usa palabras como «inmediato». Este programa mide lo que significan: crea dos millones de enteros de 8 bytes en un arreglo y en una lista, y los recorre sumándolos. Para la lista lo hace de dos maneras: con los nodos enlazados en el orden en que se reservaron, y después enlazados en orden aleatorio, con los mismos nodos, para que cada salto caiga lejos del anterior. Las funciones auxiliares segundos y campo son las de los temas anteriores:

rendimiento.c · el programa principal
enum { N = 2000000 };

struct nodo {
    long valor;
    struct nodo *siguiente;
};

static long suma_lista(const struct nodo *n)
{
    long s = 0;

    for (; n != NULL; n = n->siguiente)
        s += n->valor;
    return s;
}

int main(void)
{
    double t0;
    long base = campo("VmRSS:");

    /* 1. arreglo: un solo bloque */
    t0 = segundos();
    long *v = malloc(N * sizeof *v);
    for (long i = 0; i < N; i++)
        v[i] = i;
    double construir_v = segundos() - t0;
    long mem_v = campo("VmRSS:") - base;
    t0 = segundos();
    long suma_v = 0;
    for (long i = 0; i < N; i++)
        suma_v += v[i];
    double recorrer_v = segundos() - t0;

    /* 2. lista: un bloque por nodo, en el orden en que se pidieron */
    base = campo("VmRSS:");
    t0 = segundos();
    struct nodo **nodos = malloc(N * sizeof *nodos);   /* para poder reordenar después */
    struct nodo *cabeza = NULL, *cola = NULL;
    for (long i = 0; i < N; i++) {
        struct nodo *n = malloc(sizeof *n);
        n->valor = i;
        n->siguiente = NULL;
        nodos[i] = n;
        if (cola == NULL)
            cabeza = n;
        else
            cola->siguiente = n;
        cola = n;
    }
    double construir_l = segundos() - t0;
    long mem_l = campo("VmRSS:") - base;
    t0 = segundos();
    long suma_l = suma_lista(cabeza);
    double recorrer_l = segundos() - t0;

    /* 3. los mismos nodos, enlazados en orden aleatorio: cada salto cae lejos del anterior */
    srand(1);
    for (long i = N - 1; i > 0; i--) {
        long j = rand() % (i + 1);
        struct nodo *t = nodos[i];

        nodos[i] = nodos[j];
        nodos[j] = t;
    }
    for (long i = 0; i + 1 < N; i++)
        nodos[i]->siguiente = nodos[i + 1];
    nodos[N - 1]->siguiente = NULL;
    t0 = segundos();
    long suma_m = suma_lista(nodos[0]);
    double recorrer_m = segundos() - t0;

    printf("%d elementos de 8 bytes\n", N);
    printf("%-34s construir %.3f s   recorrer %.4f s   memoria %6ld KiB\n",
           "arreglo (un bloque)", construir_v, recorrer_v, mem_v);
    printf("%-34s construir %.3f s   recorrer %.4f s   memoria %6ld KiB\n",
           "lista, nodos contiguos", construir_l, recorrer_l, mem_l);
    printf("%-34s                   recorrer %.4f s\n", "lista, nodos en desorden", recorrer_m);
    printf("sumas: %ld %ld %ld\n", suma_v, suma_l, suma_m);
    for (long i = 0; i < N; i++)
        free(nodos[i]);
    free(nodos);
    free(v);
    return 0;
}
terminal · compilado con -O2
$ gcc -std=c17 -Wall -Wextra -O2 -o rendimiento rendimiento.c && ./rendimiento
2000000 elementos de 8 bytes
arreglo (un bloque)                construir 0.004 s   recorrer 0.0009 s   memoria  15744 KiB
lista, nodos contiguos             construir 0.047 s   recorrer 0.0056 s   memoria  78208 KiB
lista, nodos en desorden                             recorrer 0.1583 s
sumas: 1999999000000 1999999000000 1999999000000
ConstruirRecorrerMemoriaContra el arreglo
Arreglo, un bloque0,004 s0,0009 s15 744 KiB—
Lista, nodos contiguos0,047 s0,0056 s78 208 KiB12× construir, 6× recorrer, 5× memoria
Lista, nodos dispersos—0,158 s(la misma)unas 175× recorrer

Los tres resultados vienen del hardware, y los tres se ven en las columnas:

En un programa real, una lista que se construye y se modifica durante mucho tiempo, con inserciones y borrados en el medio, se parece más a la tercera fila que a la segunda: los nodos nuevos ocupan los huecos que dejaron los liberados, y el orden en la lista deja de coincidir con el orden en la memoria. Por eso la recomendación de la sección 1: el arreglo por omisión. Las listas se justifican cuando sus ventajas estructurales importan, y aun así conviene medir antes de decidir. Para mover elementos en un arreglo grande, que es su punto débil, hay un último argumento a favor del arreglo: memmove es muy rápido, y para cientos o miles de elementos copiar es más barato que seguir punteros.

6. Listas dentro de los objetos

La lista de la sección 3 tiene un defecto de diseño: hay una lista distinta para cada tipo de dato, y cada elemento requiere dos reservas, el objeto y el nodo que lo apunta. El núcleo Linux, que mantiene cientos de listas de objetos de todo tipo (procesos, archivos abiertos, dispositivos), resuelve ambos problemas con las listas intrusivas: el enlace no se pone fuera del objeto, sino adentro, como un campo más. La lista solo conoce enlaces, y el objeto se recupera con container_of, la macro del tema 25. Se hace además doblemente enlazada y circular, con un enlace especial como centinela que representa la lista misma:

enlaces.c
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* el enlace que cada objeto lleva adentro: la lista no sabe qué objetos une */
struct enlace {
    struct enlace *anterior;
    struct enlace *siguiente;
};

#define contenedor_de(ptr, tipo, campo) \
    ((tipo *) ((char *) (ptr) - offsetof(tipo, campo)))

/* la cabeza es un enlace "centinela": una lista vacía se apunta a sí misma */
static void lista_iniciar(struct enlace *cabeza)
{
    cabeza->anterior = cabeza;
    cabeza->siguiente = cabeza;
}

static int lista_vacia(const struct enlace *cabeza)
{
    return cabeza->siguiente == cabeza;
}

/* inserta 'nuevo' justo antes de 'posicion'; sin casos especiales */
static void insertar_antes(struct enlace *nuevo, struct enlace *posicion)
{
    nuevo->anterior = posicion->anterior;
    nuevo->siguiente = posicion;
    posicion->anterior->siguiente = nuevo;
    posicion->anterior = nuevo;
}

static void agregar_final(struct enlace *cabeza, struct enlace *nuevo)
{
    insertar_antes(nuevo, cabeza);        /* antes del centinela = al final */
}

static void agregar_inicio(struct enlace *cabeza, struct enlace *nuevo)
{
    insertar_antes(nuevo, cabeza->siguiente);
}

static void quitar(struct enlace *e)
{
    e->anterior->siguiente = e->siguiente;
    e->siguiente->anterior = e->anterior;
    e->anterior = NULL;
    e->siguiente = NULL;
}

struct tarea {
    int id;
    char nombre[16];
    struct enlace en_lista;
};

static struct tarea *tarea_nueva(int id, const char *nombre)
{
    struct tarea *t = malloc(sizeof *t);

    if (t == NULL)
        return NULL;
    t->id = id;
    snprintf(t->nombre, sizeof t->nombre, "%s", nombre);
    return t;
}

static void mostrar(const char *que, const struct enlace *cabeza)
{
    printf("%-24s", que);
    for (const struct enlace *e = cabeza->siguiente; e != cabeza; e = e->siguiente) {
        const struct tarea *t = contenedor_de(e, const struct tarea, en_lista);

        printf(" %d:%s", t->id, t->nombre);
    }
    printf("  |  al revés:");
    for (const struct enlace *e = cabeza->anterior; e != cabeza; e = e->anterior) {
        const struct tarea *t = contenedor_de(e, const struct tarea, en_lista);

        printf(" %d", t->id);
    }
    printf("\n");
}

int main(void)
{
    struct enlace cabeza;
    struct tarea *t[4];
    const char *nombres[4] = {"compilar", "probar", "empaquetar", "publicar"};

    lista_iniciar(&cabeza);
    printf("vacía: %s\n", lista_vacia(&cabeza) ? "sí" : "no");
    for (int i = 0; i < 4; i++) {
        t[i] = tarea_nueva(i + 1, nombres[i]);
        if (t[i] == NULL)
            return 1;
    }
    agregar_final(&cabeza, &t[1]->en_lista);
    agregar_final(&cabeza, &t[2]->en_lista);
    agregar_inicio(&cabeza, &t[0]->en_lista);
    agregar_final(&cabeza, &t[3]->en_lista);
    mostrar("1..4:", &cabeza);

    quitar(&t[1]->en_lista);
    free(t[1]);
    mostrar("quitar la 2:", &cabeza);
    quitar(&t[0]->en_lista);
    free(t[0]);
    mostrar("quitar la primera:", &cabeza);
    quitar(&t[3]->en_lista);
    free(t[3]);
    mostrar("quitar la última:", &cabeza);
    quitar(&t[2]->en_lista);
    free(t[2]);
    printf("vacía: %s\n", lista_vacia(&cabeza) ? "sí" : "no");
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o enlaces enlaces.c && ./enlaces
vacía: sí
1..4:                    1:compilar 2:probar 3:empaquetar 4:publicar  |  al revés: 4 3 2 1
quitar la 2:             1:compilar 3:empaquetar 4:publicar  |  al revés: 4 3 1
quitar la primera:       3:empaquetar 4:publicar  |  al revés: 4 3
quitar la última:       3:empaquetar  |  al revés: 3
vacía: sí
$ valgrind --leak-check=full ./enlaces 2>&1 | grep -E 'total heap|All heap|ERROR SUMMARY'
  total heap usage: 5 allocs, 5 frees, 4,256 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

Hay tres ventajas, y se ven en el código:

La macro depende de que el enlace sea un campo de la estructura: la dirección del objeto es la del enlace menos el desplazamiento del campo (offsetof, tema 25), que es 24 para en_lista en esta struct tarea (4 bytes de id, 16 de nombre y 4 de relleno para alinear el enlace a 8). El precio es que el código se vuelve menos obvio, y que el compilador no comprueba que el puntero y el tipo coincidan: si e no es el en_lista de una struct tarea, el resultado es una dirección sin sentido. La estructura de datos «lista circular con centinela» y la técnica del «enlace intrusivo» son una de las ideas más reutilizadas del código de sistemas en C.

7. Colas y un planificador

Una cola (FIFO, el primero en entrar es el primero en salir) se construye con una lista simple que tiene cabeza y cola: se agrega por la cola y se saca por la cabeza, ambas operaciones inmediatas. Una pila (LIFO) es lo mismo con una sola punta, y se hace mejor con un arreglo dinámico: vector_agregar apila y quitar el último desapila. Las colas están en el corazón de un sistema operativo: la cola de procesos listos para correr, la de peticiones a un disco, la de paquetes de una red. Este programa simula la primera, con el algoritmo de planificación más conocido, round-robin: cada proceso corre un tiempo fijo, el quantum, y si no terminó vuelve al final de la cola:

planificador.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct proceso {
    int pid;
    char nombre[16];
    int restante;                         /* unidades de CPU que todavía necesita */
    int llegada;
    struct proceso *siguiente;
};

/* una cola FIFO: se agrega por la cola y se saca por la cabeza, ambos en O(1) */
struct cola {
    struct proceso *cabeza;
    struct proceso *cola;
};

static void encolar(struct cola *c, struct proceso *p)
{
    p->siguiente = NULL;
    if (c->cola == NULL)
        c->cabeza = p;
    else
        c->cola->siguiente = p;
    c->cola = p;
}

static struct proceso *desencolar(struct cola *c)
{
    struct proceso *p = c->cabeza;

    if (p != NULL) {
        c->cabeza = p->siguiente;
        if (c->cabeza == NULL)
            c->cola = NULL;
        p->siguiente = NULL;
    }
    return p;
}

static struct proceso *crear(int pid, const char *nombre, int necesita)
{
    struct proceso *p = malloc(sizeof *p);

    if (p == NULL)
        return NULL;
    p->pid = pid;
    snprintf(p->nombre, sizeof p->nombre, "%s", nombre);
    p->restante = necesita;
    p->llegada = 0;
    p->siguiente = NULL;
    return p;
}

int main(void)
{
    enum { QUANTUM = 3 };
    struct cola listos = {NULL, NULL};
    struct { int pid; const char *nombre; int necesita; } tabla[] = {
        {1, "init", 5}, {845, "sshd", 2}, {1532, "bash", 8}, {2001, "gcc", 12},
    };
    int reloj = 0, terminados = 0, total_espera = 0, total_retorno = 0;

    for (size_t i = 0; i < sizeof tabla / sizeof tabla[0]; i++) {
        struct proceso *p = crear(tabla[i].pid, tabla[i].nombre, tabla[i].necesita);

        if (p == NULL)
            return 1;
        encolar(&listos, p);
    }
    printf("planificador round-robin, quantum = %d\n", QUANTUM);
    printf("  t  pid  proceso  corre  queda\n");

    struct proceso *p;
    while ((p = desencolar(&listos)) != NULL) {
        int corre = p->restante < QUANTUM ? p->restante : QUANTUM;

        p->restante -= corre;
        printf("%3d %4d  %-7s %5d  %5d%s\n", reloj, p->pid, p->nombre, corre, p->restante,
               p->restante == 0 ? "   terminó" : "");
        reloj += corre;
        if (p->restante > 0) {
            encolar(&listos, p);          /* vuelve al final de la cola */
        } else {
            int necesito = 0;

            for (size_t i = 0; i < sizeof tabla / sizeof tabla[0]; i++)
                if (tabla[i].pid == p->pid)
                    necesito = tabla[i].necesita;
            total_retorno += reloj;
            total_espera += reloj - necesito;
            terminados++;
            free(p);                      /* terminó: el planificador deja de ser su dueño */
        }
    }
    printf("%d procesos; retorno medio %.2f, espera media %.2f\n", terminados,
           (double) total_retorno / terminados, (double) total_espera / terminados);
    return 0;
}
terminal
$ gcc -std=c17 -Wall -Wextra -g -o planificador planificador.c && ./planificador
planificador round-robin, quantum = 3
  t  pid  proceso  corre  queda
  0    1  init        3      2
  3  845  sshd        2      0   terminó
  5 1532  bash        3      5
  8 2001  gcc         3      9
 11    1  init        2      0   terminó
 13 1532  bash        3      2
 16 2001  gcc         3      6
 19 1532  bash        2      0   terminó
 21 2001  gcc         3      3
 24 2001  gcc         3      0   terminó
4 procesos; retorno medio 16.50, espera media 9.75
$ valgrind --leak-check=full ./planificador 2>&1 | grep -E 'total heap|All heap|ERROR SUMMARY'
  total heap usage: 5 allocs, 5 frees, 4,256 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

El registro muestra cómo se reparte la CPU. init necesita 5 unidades: corre 3, vuelve al final de la cola, y termina en su segunda vuelta con 2; sshd, que necesita solo 2, termina en su primer turno sin completar el quantum; gcc, con 12, da cuatro vueltas. La cola nunca se recorre: encolar y desencolar solo tocan la cabeza o la cola, y un proceso que vuelve a la cola es el mismo bloque, que cambia de lugar con dos punteros, sin copiar ni reservar. Las métricas son las clásicas de la planificación: el tiempo de retorno, desde que llega hasta que termina, y el de espera, el que pasó en la cola sin correr; el round-robin reparte la CPU de forma equitativa, a costa de que los procesos largos tarden más. El tema de la planificación se estudia a fondo en el curso de Fundamentos de Sistemas Operativos; aquí lo importante es que la estructura de datos es la que cualquier planificador real usa.

Y hay una decisión de propiedad escondida en el programa: cada proceso lo crea main con crear, lo posee la cola mientras está en ella, lo posee el bucle cuando lo saca, y lo libera main cuando termina. Nadie lo copia, y al final no queda ninguno: cinco reservas, cinco liberaciones. Esa pregunta, quién es dueño, es el tema de la sección siguiente.

8. Quién es dueño de cada elemento

Una estructura de datos dinámica contiene elementos, y cuando esos elementos tienen memoria propia, como un texto, surge la pregunta del tema 28 en su forma más difícil: ¿quién libera el texto de un elemento cuando la estructura lo guarda? Hay tres respuestas, y la interfaz de la estructura debe elegir una y documentarla:

PolíticaAl agregar…Al destruir la estructura…Riesgo
Copiar (la estructura es dueña)Guarda una copia propia del dato (strdup).Libera todas las copias.El costo de copiar.
Prestar (la estructura no es dueña)Guarda la dirección que le dan, sin copiar.No libera nada de los elementos.El dato debe seguir vivo y sin cambiar mientras la estructura lo use.
Transferir (cambia el dueño)Toma posesión de un bloque que le entrega quien llama.Libera lo que todavía posee. Al sacar un elemento, se lo devuelve a quien lo pide, que pasa a ser su dueño.Olvidar quién es dueño después de la transferencia.

Este programa implementa una lista de textos con las políticas de copiar y transferir:

textos.c
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

struct nodo {
    char *texto;                          /* memoria que pertenece a la lista */
    struct nodo *siguiente;
};

struct textos {
    struct nodo *cabeza;
    struct nodo *cola;
};

/* La lista COPIA el texto: desde ahora es dueña de su copia. */
static int textos_agregar(struct textos *l, const char *texto)
{
    struct nodo *n = malloc(sizeof *n);

    if (n == NULL)
        return -1;
    n->texto = strdup(texto);
    if (n->texto == NULL) {               /* deshacer lo ya reservado */
        free(n);
        return -1;
    }
    n->siguiente = NULL;
    if (l->cola == NULL)
        l->cabeza = n;
    else
        l->cola->siguiente = n;
    l->cola = n;
    return 0;
}

/* Quita el primero y TRANSFIERE la propiedad: quien llama debe hacer free del texto. */
static char *textos_sacar(struct textos *l)
{
    struct nodo *n = l->cabeza;

    if (n == NULL)
        return NULL;
    char *texto = n->texto;

    l->cabeza = n->siguiente;
    if (l->cabeza == NULL)
        l->cola = NULL;
    free(n);                              /* el nodo se libera; el texto sale de la lista */
    return texto;
}

/* Destruir libera lo que la lista todavía posee: los textos que quedan y los nodos. */
static void textos_destruir(struct textos *l)
{
    char *t;

    while ((t = textos_sacar(l)) != NULL)
        free(t);
}

int main(void)
{
    struct textos l = {NULL, NULL};
    char buffer[32];
    const char *entrada[] = {"Ana", "Luis", "Beatriz", "Carlos"};

    for (size_t i = 0; i < sizeof entrada / sizeof entrada[0]; i++) {
        snprintf(buffer, sizeof buffer, "%s", entrada[i]);     /* un único buffer, reutilizado */
        if (textos_agregar(&l, buffer) != 0) {
            fprintf(stderr, "sin memoria\n");
            textos_destruir(&l);
            return 1;
        }
    }
    for (int i = 0; i < 2; i++) {
        char *t = textos_sacar(&l);        /* ahora el texto es nuestro */

        printf("sacado: %s\n", t);
        free(t);
    }
    printf("quedan:");
    for (const struct nodo *n = l.cabeza; n != NULL; n = n->siguiente)
        printf(" %s", n->texto);
    printf("\n");
    textos_destruir(&l);
    return 0;
}
terminal
$ ./textos
sacado: Ana
sacado: Luis
quedan: Beatriz Carlos
$ valgrind --leak-check=full ./textos 2>&1 | grep -E 'total heap|All heap|ERROR SUMMARY'
  total heap usage: 9 allocs, 9 frees, 4,184 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

La política de prestar es la más barata y la más peligrosa, y el error típico es prestar algo que se reutiliza:

prestamo.c · el mismo buffer, guardado por dirección y por copia
    for (int i = 0; i < MAX; i++) {
        snprintf(buffer, sizeof buffer, "%s", entrada[i]);
        prestados[i] = buffer;            /* error: todos apuntan al mismo arreglo */
        copias[i] = strdup(buffer);
    }
terminal
$ ./prestamo
0: prestado = "Carlos"   copia = "Ana"
1: prestado = "Carlos"   copia = "Luis"
2: prestado = "Carlos"   copia = "Beatriz"
3: prestado = "Carlos"   copia = "Carlos"

Las cuatro entradas «prestadas» apuntan al mismo arreglo, que contiene el último nombre leído: al final, todas dicen «Carlos». Es uno de los errores más comunes al guardar cadenas leídas en un bucle, y ocurre cuando el texto que se guarda es un puntero a un buffer que va a cambiar. Con memoria prestada, quien presta debe garantizar que el dato dure y no cambie; con una variable local reutilizada, no puede. Otra variante del mismo error es prestar un bloque que se libera antes que la estructura: un puntero colgante guardado adentro.

Estas reglas resumen el bloque:

Con esto termina el bloque de la memoria dinámica. Faltan estructuras más elaboradas, como árboles de búsqueda y tablas de dispersión, que se construyen con las mismas piezas: nodos con punteros, una política de propiedad, y destruir en el orden inverso al de creación. En el siguiente bloque dejamos la memoria y pasamos a la otra mitad de la programación en C para sistemas: cómo se convierten los archivos fuente en un programa, cómo se unen y cómo se organizan en bibliotecas.

9. Errores frecuentes

ErrorQué pasaCorrección
Guardar un puntero a un elemento de un arreglo dinámicoUn agregado mueve el bloque y el puntero queda colgante.Guardar el índice.
Crecer el arreglo de a un elementoUna llamada a realloc por elemento.Duplicar la capacidad.
Confundir cantidad y capacidadSe lee más allá de los elementos en uso, o se pierde memoria.Dos campos, con un invariante.
p = realloc(p, …) en el vectorSi falla, se pierde el bloque y los datos.Un puntero temporal.
Olvidar actualizar la cola al quitar el último nodoLa cola apunta a memoria liberada.Mantener el invariante en cada operación; mostrarlo en las pruebas.
Casos especiales para la cabeza al quitar un nodoCódigo largo y propenso a errores.El puntero a puntero, o un centinela.
for (n = cabeza; n; n = n->siguiente) free(n);Lee n->siguiente después de liberar.Guardar el siguiente antes de free.
Liberar una lista con recursiónUn marco de pila por nodo: Segmentation fault con listas largas.Un bucle.
Liberar la cabeza de la lista y creer que es suficienteLos demás nodos se pierden: fuga.Recorrer y liberar todos.
Guardar un puntero a un buffer que se reutilizaTodas las entradas apuntan al último contenido.Copiar con strdup.
El mismo elemento en dos estructuras que lo liberanDoble liberación.Un solo dueño; la otra estructura presta.
No documentar quién libera lo que devuelve una funciónFugas o dobles liberaciones en quien la usa.Comentar la política en cada función.
Usar una lista donde un arreglo bastaba5 veces la memoria y recorridos 6 a 175 veces más lentos.Arreglo por omisión; medir.
contenedor_de con el campo o el tipo equivocadosUna dirección sin sentido, sin ningún aviso.Revisar la pareja tipo y campo; probar con ASan.

10. Resumen

11. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Cuándo conviene un arreglo dinámico y cuándo una lista enlazada?

El arreglo conviene por omisión: acceso por índice inmediato, menos memoria y recorridos rápidos. La lista conviene cuando se agrega o quita muy a menudo al principio o en el medio (con la posición a mano), o cuando otras partes del programa guardan punteros a los elementos y estos no deben moverse. Casi siempre, conviene medir antes de decidir.

2. ¿Qué dicen la cantidad y la capacidad de un arreglo dinámico, y por qué se llevan por separado?

La cantidad es cuántos elementos están en uso; la capacidad, cuántos caben en el bloque actual sin pedir más memoria. Se llevan por separado porque el bloque se agranda en saltos, duplicando, y no con cada elemento: la mayoría de los agregados no llaman a realloc, y el costo promedio de agregar es constante.

3. ¿Qué pasa con un puntero a v.datos[3] guardado antes de agregar elementos al vector?

Si el agregado hace crecer el bloque y realloc lo mueve, el puntero queda colgante: apunta al bloque viejo, ya liberado. Es el uso después de liberar del tema 29. Se evita guardando el índice, que sigue valiendo después del movimiento.

4. ¿Para qué sirve el puntero a la cola de una lista simple?

Para agregar al final sin recorrer la lista: cola->siguiente = nuevo es una operación inmediata. Sin él, agregar al final requiere recorrer los n nodos. A cambio, hay que mantenerlo al día en cada operación que pueda cambiar el último nodo, incluido quitar.

5. ¿Cómo evita el puntero a puntero el caso especial de la cabeza al quitar un nodo?

La variable enlace guarda la dirección del puntero que apunta al nodo actual: al principio es &l->cabeza, y después la dirección del campo siguiente del nodo anterior. *enlace = borrado->siguiente modifica ese puntero, sea el de la cabeza o el de un nodo intermedio, con la misma línea.

6. ¿Por qué no se puede liberar una lista con for (n = cabeza; n; n = n->siguiente) free(n);?

Porque la expresión n = n->siguiente se evalúa después de free(n), y lee un campo de un bloque ya liberado: un uso después de liberar. La forma correcta guarda el siguiente en una variable antes de liberar el nodo.

7. ¿Por qué liberar una lista con una función recursiva puede terminar en Segmentation fault?

Porque cada nodo es un nivel de recursión, con su marco en la pila, y la pila tiene un tamaño limitado (8 MiB por omisión). Con marcos de 32 bytes, unas 260 000 llamadas la agotan. Como la lista puede tener la cantidad de elementos que quiera quien usa el programa, la liberación se hace con un bucle.

8. ¿Por qué recorrer una lista con los nodos dispersos puede ser decenas de veces más lento que con los nodos contiguos, si hace el mismo trabajo?

Porque el costo está en los accesos a la memoria, no en la suma. Con los nodos contiguos, la caché y el prefetcher traen por adelantado los siguientes. Con los nodos dispersos, cada n->siguiente cae en un lugar que no está en la caché, y el procesador espera a la memoria principal en cada paso, sin poder adivinar la dirección hasta leer el nodo actual.

9. ¿Qué es una lista intrusiva y qué ventajas tiene sobre la lista de nodos separados?

Es una lista cuyo enlace es un campo del propio objeto: la lista solo maneja enlaces y recupera el objeto con container_of. Requiere una reserva por objeto en lugar de dos, la misma lista sirve para cualquier tipo, y un objeto con varios enlaces puede estar en varias listas a la vez. El núcleo Linux las usa para casi todo.

10. ¿Para qué sirve el centinela en una lista circular doble?

Es un enlace que representa la lista y que hace que todo nodo tenga siempre un anterior y un siguiente, incluso en la lista vacía, donde se apunta a sí mismo. Con él, insertar y quitar son las mismas operaciones para el principio, el medio y el final, sin comprobar casos de borde con NULL.

11. ¿Qué diferencia hay entre las políticas de «copiar», «prestar» y «transferir» la propiedad?

Con «copiar», la estructura guarda una copia propia y la libera al destruirse. Con «prestar», guarda la dirección que le dan y no libera nada: el dato debe vivir y no cambiar mientras lo use. Con «transferir», toma posesión de un bloque que le entregan, o lo entrega a quien lo saca, que pasa a ser su dueño y debe liberarlo.

12. En prestamo.c, ¿por qué las cuatro entradas «prestadas» dicen «Carlos»?

Porque guardan la dirección de buffer, el mismo arreglo en las cuatro vueltas, y el último contenido que se escribió en él fue «Carlos». Las copias con strdup, en cambio, son bloques distintos, uno por entrada. Prestar un buffer que se reutiliza es un error de propiedad: el dato prestado no dura.

13. En el planificador round-robin, ¿qué estructura de datos se usa y por qué?

Una cola FIFO hecha con una lista simple con cabeza y cola. Se saca el proceso de la cabeza, se lo deja correr un quantum y, si no terminó, se lo agrega por la cola. Las dos operaciones son inmediatas y no recorren la lista; el mismo bloque del proceso vuelve a la cola sin copiarse.

Para practicar

  1. Amplía vector.c para guardar double, y después para que guarde elementos de cualquier tipo, recibiendo el tamaño del elemento en vector_iniciar y usando void * y memcpy. ¿Qué se pierde frente a la versión de int?
  2. Agrega a vector.c una función vector_ordenar con qsort (tema 20) y otra vector_buscar con bsearch. ¿Qué precondición tiene la segunda?
  3. Mide cuántas veces se llama a realloc al agregar un millón de elementos al vector, y compara con una versión que crece de a 1000 elementos. ¿Cuál es el costo de memoria sobrante de cada una?
  4. Escribe lista_insertar_ordenado, que inserte un valor en una lista ordenada manteniendo el orden, con el puntero a puntero. Pruébala con la inserción al principio, en el medio y al final, y comprueba que la cola queda bien.
  5. Escribe una lista doblemente enlazada con centinela para int, no intrusiva (con un nodo que contiene el valor y dos punteros), y compárala con enlaces.c: ¿cuántas reservas hay por elemento? ¿Y cuántas líneas de casos especiales se evitan?
  6. Agrega a enlaces.c un segundo enlace, en_pendientes, a struct tarea, y mantén dos listas: todas las tareas y las pendientes. Una tarea terminada sale de la segunda pero no de la primera. ¿Qué hay que cambiar en contenedor_de?
  7. Modifica el planificador para que use un quantum distinto por proceso, o para que los procesos lleguen en momentos distintos (el campo llegada ya está declarado). Calcula el tiempo de espera medio de round-robin con quantum 1, 3 y 100, y explica cómo se comporta con un quantum enorme.
  8. Escribe una pila de cadenas con un arreglo dinámico que sea dueña de sus textos (política de copiar), y úsala para comprobar si los paréntesis de una expresión están balanceados. Verifica el programa con ASan y con Valgrind.