Programación en C con Memoria Dinámica

Gestión Manual en Lenguaje C

El lenguaje C implementa el tipo de gestión manual, es decir que cada programa debe explícitamente pedir memoria y liberarla.

Para esto, necesitamos un API. En C tenemos un conjunto de funciones para:

    • Pedir memoria: malloc()
    • Liberar memoria: free()

Alocar memoria (malloc) y liberarla (free)

La función malloc definida en stdlib.h permite reservar memoria de un tamaño dado. Su contrato es:

void *malloc(size_t tamanio);

Y se lee así:

    • Reserva la cantidad de memoria especificada por parámetro.
    • Retorna la dirección de la porción de memoria que reservó. Es decir que retorna un puntero. De ahí que primer vimos acá algunas definiciones iniciales de qué son los 1punteros.

Veamos un ejemplo:

    int *puntero = malloc(4);
    printf("Direccion del Puntero: %i\n", puntero);
    printf("Contenido de la memoria: %i", *puntero);

En este caso reservamos 4 bytes, y asignamos la dirección de memoria a la variable de nombre puntero.

El resultado es:

Direccion del Puntero: 0x109c010
Contenido de la memoria: 0

Luego podemos modificar el valor

*puntero = 42;
printf("Contenido de la memoria: %i", *puntero);

Nota:

La llamada a malloc podría "fallar" si por algún motivo el sistema operativo no puede darle la cantidad de memoria especificada a nuestro programa. Cuando este sucede malloc retorna NULL.

Entonces, en teoría deberíamos siempre realizar este checkeo:

    int *puntero = malloc(4);
    if (puntero == NULL) {    
        fprintf(stderr, "La aplicacion no pudo reservar memoria y se va a cerrar!\n");
        exit(EXIT_FAILURE);
    }
    ...

Ejemplo

Veamos un ejemplo en el cual el usuario ingresa una cantidad variable de números por consola. Primero el sistema le pide que especifiqué la cantidad de números que va a ingresar, y luego de ingresados, los imprime, incrementando en 1 cada número.

int main(void) {
    int cantidad = leerCantidad();
    int* numeros = malloc(cantidad * sizeof(int));
    leer(cantidad, numeros);
    imprimir(cantidad, numeros);
    free(numeros);
    return EXIT_SUCCESS;
}

Y las funciones adicionales podrían ser:

int leerCantidad() {
    int cantidad;
    printf("Cuantos numeros va a ingresas ?: ");
    scanf("%i", &cantidad);
    return cantidad;
}
void leer(int cantidad, int *numeros) {
    int i = 0;
    for (;i < cantidad; i++) {
        scanf("%i", &numeros[i]);
    }
}
void imprimir(int cantidad, int *numeros) {
    int i = 0;
    for (;i < cantidad; i++) {
        printf("%i ", numeros[i] + 1);
    }
}

Lo interesante aquí están en la función main(). Veamos un par de observaciones:

    • Si bien vimos que malloc recibe la cantidad de bytes a allocar, en general nosotros queremos guardar un tipo en especial, por ejemplo int, o en este caso, muchos int's. Entonces, siempre es recomendable por más que sepamos cuánto ocupa un int utilizar la función adicional sizeof que dado un tipo nos retorna su tamaño en memoria. Así, en nuestro ejemplo estamos allocando sizeof(int) * cantidad. Para poder guardar N int's.
    • La otra parte interesante, es que al puntero numeros se lo puede utilizar de forma bastante similar a un arreglo/vector (array). Por ejemplo iterándolo (o indexándolo) como en leer e imprimir, con numeros[i]. Si bien son similares, no son exáctamente iguales. Probar qué sucede si llaman a sizeof() con un vector de int's 100 elementos, por ejemplo, respecto de invocarlo con un puntero a int's con memoria para 100 elementos ! :)

Free

Es importante no olvidarnos que, así que nos encargamos de alocar memoria, tenemos que explícitamente liberarla, a través de invocar la función free() pasando como parámetro el puntero.

Así:

free(miPuntero);

Los lineamientos generales acá deberían ser parecidos a las buenas prácticas de programación con variables locales.

Es decir, vamos a alocar el puntero lo más tarde posible, es decir recién cuando lo vamos a necesitar sí o sí.

Y vamos a liberarlo "cuanto antes", es decir, lo más pronto posible, cuando sabemos que ya no necesitamos recordar más su contenido.

En lo posible, sería bueno que la misma función que lo aloca, lo libere. Así tenemos un principio de "cercanía". Aunque no siempre esto es posible. En nuestro ejemplo, el main() es quien aloca, luego llama a la lógica de negocio como funciones más chiquitas (ingresar los números e imprimirlos), y finalmente invoca al free().

Punteros vs Vectores (Arrays) y Aritmética de Punteros

En el ejemplo anterior alocamos memoria dinámicamente, pero no para almacenar un único entero, sino muchos (cantidad variable). Pero lo curioso es que el tipo de variable era el mismo que si el puntero apuntara a un único entero, es decir int *.

Esto es porque el puntero simplemente ocupa espacio en la memoria, para guardar la dirección de lo que apunta. El contenido del puntero es una dirección de memoria.

Y el lenguaje permite utilizar ciertos operadores sobre estas direcciones de memoria, por ejemplo para movernos o leer el contenido en las direcciones que siguen.

Esto se hace, como vimos, con la misma sintaxis que el acceso por índice a un array (vector). Es decir:

miPunter[unaPosicion]

Con la particularidad de que C se encarga de calcular cuántos bytes sumarle a la dirección inicial del puntero para acceder a la posición dado. Nosotros no tenemos que encargarnos (por suerte) de hacer la cuenta.

Es decir, si tengo un puntero de int's quiero acceder al segundo int, hago: miPuntero[1] y no necesito calcular el corriemiento en bytes (4 bytes adelante de la posición inicial.

Otra forma de expresar esto es:

*(miPuntero + posicion)

Acá estamos usando el operador más (+). C nuevamente se encarga de ver de qué tipo es el puntero ( en nuestro caso int), y en lugar de sumarle el entero de la posición, primero lo multiplica por los bytes.

Es decir que en el ejemplo anterior para acceder al segundo entero hacemos:

*(miPuntero + 1)

Y en realidad C estará haciendo:

direccionDelPunteroEnBytes + 4 * 1

A la capacidad de poder operar las direcciones de memoria de esta forma se la conoce como aritmética de punteros.

Volviendo a vectores vs punteros, el tratamiento es similar, porque la variable de tipo array se pasa su dirección de memoria.

Otros casos

Realloc

Esta función se utiliza para, dado un espacio de memoria, del cual tenemos un puntero a él, redimensionarlo, ya sea para hacerlo más chico o más grande.

La firma de la función es:

void* realloc(void* ptr, size_t size);

Recibe el puntero a redimensionar y el nuevo tamaño. Y retorna un puntero a la dirección de memoria del nuevo tamaño.

Por qué retorna un puntero ? Porque internamente la función quizás deba reubicar el segmento a una nueva dirección.

Los casos posibles serían:

    • Si se intenta "agrandar" el espacio:
      • y hay lugar contiguo, se mantendrá el mismo puntero.
      • si las posiciones de memoria contiguas ya están utilizadas, la función va a:
        • alocar otra sección nueva por el tamañano "nuevo"
        • copiar el contenido de la anterior (es decir que mantiene el contenido)
        • liberar la porción anterior que ya no necesitamos.
      • Si no hay memoria suficiente devolverá NULL (como ya vimos en malloc)
    • Si se intenta "achicar" el espacio, simplemente va a liberar la sección "extra" de memoria para uso futuro.

Calloc

Es similar a la función malloc con la diferencia de que:

    • Inicializa la memoria reservada a 0 (cero)
    • La firma de la función es distinta, en lugar de recibir la cantidad de bytes recibe la cantidad de "slots" y el tamaño del tipo de dato.
void *calloc(size_t nmemb, size_t size);
    • nmemb: es la cantidad de "slots"
    • size: es el tamaño de cada uno.