Memoria Dinámica#
Salvo en aplicaciones particulares, como sistemas de tiempo real, críticos, o muy limitados, el uso de memoria dinámica es fundamental. En el lenguaje de programación C, el uso de este tipo de memoria es sencillo, pero es necesario entender los conceptos asociados a arreglos y punteros, a la vez que se tiene un entendimiento claro sobre la distribución en memoria de los distintos tipos de datos. Esto se debe a que, cuando no se hace uso de la memoria dinámica, es el compilador quien gestiona la memoria (estática) por nosotros. Sin embargo ¿qué es la memoria dinámica?
Tenemos que destacar que, en cuanto a memoria se refiere, físicamente la computadora posee la memoria del disco rígido,
la memoria RAM y la memoria del procesador (la memoria caché).
Al ejecutar un programa, este se carga del disco rígido y se lo ubica en la memoria RAM.
Cuando sólo se utiliza memoria estática, todas las variables que el programa necesitará están previamente definidas y
así, al iniciar una función—como es el caso de main
—el compilador agrega las instrucciones necesarias para
reservar la memoria que la función necesita, en el stack.
Vimos que al definir un arreglo adentro de una función, como
estamos indicando que esa función necesita 8192 bytes (8 kiB) en su stack para operar. Sin embargo, no siempre podemos saber a priori cuántos datos vamos a tener que utilizar. Es allí cuando entra en juego la memoria dinámica. La memoria dinámica se le pide al sistema operativo, y éste nos la otorga o no, buscando regiones de memoria libre en el heap. Una vez que esta memoria no es más utilizada, debe ser devuelta al sistema operativo, es decir, se le debe indicar que ya no la utilizamos y puede ser otorgada a otra aplicación (o la misma si hacemos un nuevo pedido de memoria). Estas operaciones se realizan utilizando funciones de la biblioteca estándar.
La biblioteca estándar (en stdlib.h
) posee 4 funciones para el manejo de memoria en forma dinámica:
malloc()
,free()
,realloc()
, ycalloc()
.
La descripción de estas funciones no dice:
The malloc() function allocates size bytes and returns a pointer to the allocated memory. The memory
is not initialized. If size is 0, then malloc() returns either NULL, or a unique pointer value that
can later be successfully passed to free().
The free() function frees the memory space pointed to by ptr, which must have been returned by a previ‐
ous call to malloc(), calloc(), or realloc(). Otherwise, or if free(ptr) has already been called be‐
fore, undefined behavior occurs. If ptr is NULL, no operation is performed.
The calloc() function allocates memory for an array of nmemb elements of size bytes each and returns a
pointer to the allocated memory. The memory is set to zero. If nmemb or size is 0, then calloc() re‐
turns either NULL, or a unique pointer value that can later be successfully passed to free(). If the
multiplication of nmemb and size would result in integer overflow, then calloc() returns an error. By
contrast, an integer overflow would not be detected in the following call to malloc(), with the result
that an incorrectly sized block of memory would be allocated:
malloc(nmemb * size);
The realloc() function changes the size of the memory block pointed to by ptr to size bytes. The con‐
tents will be unchanged in the range from the start of the region up to the minimum of the old and new
sizes. If the new size is larger than the old size, the added memory will not be initialized. If ptr
is NULL, then the call is equivalent to malloc(size), for all values of size; if size is equal to zero,
and ptr is not NULL, then the call is equivalent to free(ptr) (this behavior is nonportable; see
NOTES). Unless ptr is NULL, it must have been returned by an earlier call to malloc(), calloc(), or
realloc(). If the area pointed to was moved, a free(ptr) is done.
¿Cómo reservar memoria dinámica?#
La función malloc
, cuyo prototipo es
es una función que nos permite pedir un bloque de memoria de tamaño size
.
Por ejemplo, si quisiéramos memoria para un entero, ejecutaríamos malloc(sizeof(int))
, mientras que si quisiéramos
un bloque para un double
llamaríamos a malloc
del siguiente modo: malloc(sizeof(double))
.
Por otro lado, si quisiéramos un arreglo de 10 float
s la invocación sería malloc(10 * sizeof(float))
,
mientras que si fuese para un arreglo de 10 punteros a float
haríamos malloc(10 * sizeof(float *))
.
A través de malloc
le solicitamos al sistema operativo una región de un determinado tamaño y éste nos devuelve la
dirección de memoria donde dicho bloque comienza, o NULL
en caso de no poder otorgar un bloque del tamaño
solicitado.
Al recibir NULL
en caso de errores, es fácil validar si el sistema operativo nos asignó el bloque de memoria o no,
siguiendo un esquema como el siguiente:
uso de malloc() | |
---|---|
donde nmemb
es la cantidad de elementos que queremos en el arreglo y T
puede ser cualquier tipo: double
, int
,
long unsigned
, float *
, char *
, etc.
Podemos ver que siempre se cumple que si array
es de tipo T *
, entonces el resultado de malloc
se castea a ese
mismo tipo y como argumento de malloc
se utiliza un nivel de punteros menos para el sizeof()
: sizeof(T)
.
Sin embargo, a partir del estándar ISO C99, malloc()
retorna void *
, por lo que no es necesario el casteo, es
redundante.
Ejemplo: arreglo de mediciones
Consideremos una aplicación en la que utilizamos un sensor laser, pero admitimos diferentes modelos. Una forma de admitirlos es a través de una configuración que nos dice, tal vez haciendo alguna cuenta, cuántas muestras por segundo nos da dicho sensor. Sin embargo, diferentes modelos pueden dar una cantidad de muestras distintas: un modelo puede entregar 1200 muestras mientras que otro nos puede dar 7200 muestras. En vez tener un arreglo estático de 7200 valores, podríamos crear uno dinámicamente al iniciar la aplicación.
Es decir, estamos ante un caso en el que en tiempo de compilación no sabemos cuántos elementos son, pero en tiempo de ejecución sí.
Si bien no es la forma más práctica, supongamos que al comienzo del programa le solicitamos al usuario que nos indique cuántas muestras son, luego crearemos el arreglo. Este proceso se ejemplifica en el siguiente código:
Como especifica la función, malloc
no inicializa la memoria devuelta, es decir, se nos asigna un bloque de memoria, y
se nos devuelve un puntero al inicio el mismo tal y como se encontraba cuando se nos asignó.
Sin embargo, existe una función que nos permite inicializar el bloque de memoria con ceros. La función en cuestión es
Podemos ver que, a diferencia de malloc
, calloc
recibe 2 argumentos, indicando la cantidad de elementos pedidos y el
tamaño de cada elemento.
Esto nos sirve para borrar cualquier tipo de información que pudiera haber en el bloque de memoria asignado, y para
inicializar variables que tengan sentido que comiencen en cero (arreglos de números que deben inicializarse a cero,
arreglos de punteros inicializados a NULL
, estructuras, etc.).
Ejemplos#
El primer ejemplo que veremos es la creación de un vector de N enteros, y lo haremos de 2 maneras, devolviendo el puntero por el nombre de la función, y por la interfaz.
Devolución por el nombre#
En el primer caso, la función no es más que un llamado a malloc
:
Esta versión de la función es un poco verbosa, y una primera reducción que podríamos hacer es unificar la llamada a
malloc
y el if
, del siguiente modo:
Nuevamente, podemos reducir este código un poco más utilizando el operador ternario:
Y tal alguien ya se haya dado cuenta que eso que hemos hecho es retornar, en ambos casos, el resultado de la invocación
a malloc
.
Si el resultado fue satisfactorio, retornamos iarray
, de lo contrario, retornamos NULL
.
No obstante, iarray
en ese último caso almacena NULL
, por lo que también estamos retornando iarray
.
Por lo tanto podemos implementar la función del siguiente modo:
En todos los casos, la función se invoca del siguiente modo:
Devolución por la interfaz#
En este caso, necesitamos de un parámetro auxiliar para retornar el arreglo utilizando dicho parámetro. Veamos primero el prototipo:
En este caso, status_t
es un tipo definido por nosotros para indicar el resultado de la operación, mientras que el
primer argumento es un puntero al puntero a entero que queremos modificar, es decir, la función se invoca del siguiente
modo:
y su implementación es la siguiente:
Asignación y modificación de la memoria#
El siguiente ejemplo es la copia en memoria dinámica de una cadena de caracteres, función que suele llamarse strdup
(e
incluso es parte de estándar POSIX).
La forma más inmediata de implementar dicha función es:
Devolución de la memoria pedida#
Una vez que terminamos de utilizar el bloque de memoria pedido, es necesario devolverlo al sistema operativo.
A este proceso también lo llamamos liberar la memoria pedida.
Para realizar dicha tarea tenemos a la función free()
, cuyo prototipo es
Esta función recibe un puntero que nos haya devuelto malloc()
, realloc()
o calloc()
y le indica al sistema
operativo que ya no lo utilizaremos.
De este modo, el sistema operativo sabe que puede reutilizar esa región de memoria.
Si las aplicaciones no liberasen la memoria que piden cuando terminan de utilizarla, el sistema operativo en seguida se
quedaría sin memoria para otorgarle al resto de las aplicaciones.
Ejemplo: arreglo de mediciones
Volvamos sobre el ejemplo anterior, donde le solicitamos al sistema operativo una región de memoria de nmemb
double
s para almacenar las mediciones de un sensor laser.
Una vez que terminamos de hacer y procesar la medición, puede que ese arreglo no lo necesitemos más, en cuyo caso
debemos liberarlo (esto es, no vamos a reutilizarlo).
Retomando el código del ejemplo, haríamos lo siguiente
Es importante notar que el puntero debe ser uno que haya devuelto una de las funciones antes mencionadas, sino lo más
probable es que se intente liberar memoria estática, en cuyo caso se verá un error como el siguiente (por ejemplo, al
liberar el arreglo input
del ejemplo anterior):
$ ./invalid_free
free(): invalid size
Aborted (core dumped)
$ ./invalid_free
munmap_chunk(): invalid pointer
Aborted (core dumped)
$ ./invalid_free
Segmentation fault (core dumped)
Y, por otro lado, la memoria pedida sólo puede ser devuelta una vez, sino veremos errores como el siguiente:
Aunque pueden volverse más extensas las descripciones.
Es por ello que es una buena práctica asignar NULL
a un puntero una vez que se libera la memoria a la que apunta.
Fugas de memoria#
Siempre que solicitamos regiones de memoria utilizando las funciones aquí vistas, debemos devolverla al sistema operativo. Esto es porque todos los programas están en constante uso de memoria y para distintas operaciones necesitan memoria dinámica (por ejemplo, la apertura de un archivo para su lectura o escritura requiere memoria dinámica).
Lamentablemente, no son poco frecuentes las fugas de memoria (o memory leaks) en los programas. Esto es el uso de punteros a regiones de memoria dinámica que nunca son devueltos al sistema. Puede haber múltiples motivos para ello, por ejemplo, que el desarrollador espera al final del programa para liberar la memoria solicitada (lo que es un error salvo que dicha memoria se encuentre en uso hasta ese momento), o también porque se pierde la referencia a dichas regiones. El siguiente ejemplo, muestra en forma grosera una fuga de memoria
Es grosero ya que en la linea 11 (resaltada), estamos asignado v
a dv
, reemplazando el valor que tenía antes, la
dirección a la región de memoria dada por malloc
.
Una vez hecho eso, ya no sabemos a qué dirección apuntaba, y no podemos ejecutar free(dv)
.
Pero también tenemos ejemplos más sutiles, como el siguiente:
Aquí, en la línea 4 (resaltada) estamos asignando a input
una nueva región de memoria sin haber liberado la anterior,
y al hacerlo perdemos la referencia a la memoria que teníamos asignada.
Cuando los tamaños son desconocidos#
Supongamos que queremos leer, de stdin
todo lo que ingrese el usuario hasta el '\n'
.
No hay arreglo que nos alcance para asegurarnos que podremos hacerlo, por lo que debemos recurrir a la memoria dinámica.
La función clave, en este caso, es
La función recibe un puntero que debe haber sido devuelto por malloc
, calloc
o realloc
y un tamaño.
Que el puntero haya sido devuelto por una de las funciones anteriores implica que apunta a una dirección válida en el
heap, por lo que puede ser liberada (y el SO sabe cuánto espacio tenemos reservado en esa dirección).
Supongamos entonces que hemos pedido memoria con malloc
del siguiente modo
y ahora nos encontramos en la situación en que ya hemos utilizado los 3 doubles de ese arreglo pero queremos agregar
más.
Para ello, debemos solicitarle al sistema operativo que nos de más memoria, pero como queremos utilizar un arreglo, esa
memoria debe ser contigua con los otros datos.
realloc
se encarga de esa tarea.
Por ejemplo, si para los 3 doubles que pedimos antes nos fue asignado un bloque de memoria con el tamaño justo (24
bytes) y ahora necesitamos agregar un double más, éste, claramente, no puede ser ubicado a continuación del bloque
previamente asignado (porque no hay espacio).
Si este último tuviera a continuación memoria no asignada a otro proceso, sí podríamos recibir de realloc
la misma
memoria con ese bloque extendido, pero ello no es ninguna garantía de que suceda, por lo que no es algo que se deba
suponer nunca.
Como antes ya le habíamos pedido 24 bytes al sistema operativo y ahora necesitamos 1 double más, debemos solicitarle al
sistema 32 bytes, no hay una función para pedir un espacio extra, sino que se debe solicitar el total.
Al recibir el tamaño size
, realloc
busca una sección de memoria que tenga dicho tamaño (que puede iniciar en la
misma posición que el puntero dado o puede ser distinta).
Entonces, si antes habíamos iniciado data
utilizando data = malloc(3 * sizeof(double));
, ahora ejecutaremos
Es importante recordar que cada pedido de memoria que hacemos al sistema operativo puede fallar, y todas las funciones
vistas retornan NULL
en ese caso.
En el caso de realloc
, si el pedido de memoria es exitoso, la función solicita el espacio de memoria que necesitamos,
copia los datos que había en la memoria original a la nueva posición y nos devuelve un puntero válido a una dirección de
memoria del tamaño solicitado.
En caso de fallar, retorna NULL
y no hace nada con la memoria que hemos pasado, es decir, no la libera.
Es por esto que en la línea de código anterior estamos utilizando una variable auxiliar para verificar el retorno de
realloc
.
Si este nos devuelve un puntero válido, distinto de NULL
, entonces podemos reasignarlo a data
.
Si hubiésemos utilizado data
tanto en la llamada como a la izquierda del igual, data
almacenaría NULL
y la
memoria que teníamos asignada no tendríamos forma de recuperarla.
En código, esta validación posllamada a realloc
se ve de la siguiente manera:
Versión completa del código descripto
A continuación pueden ver una versión completa del código desarrollado en esta sección:
Arreglo dinámicos de arreglos dinámicos#
Si queremos crear un arreglo dinámico de arreglos dinámicos, es decir, un puntero doble, debemos seguir las mismas reglas antes vistas:
- Siempre validar
malloc
,calloc
orealloc
. - Siempre que llamemos a
realloc
, usar un puntero distinto para la llamada y para la asignación. - Verificar que si a la izquierda de la asignación tenemos un nivel N de punteros, el casteo a la derecha es del mismo
nivel N y en la llamada seguramente utilizemos un
sizeof(T)
dondeT
es un nivel de punteros menos, es decir, N-1.
Matriz dinámica#
El primer ejemplo que podemos considerar es la creación de una matriz dinámica. Para ello, debemos crear un vector dinámico de arreglos de doubles (un vector dinámico de punteros a double), como se muestra en la siguiente figura:
Un vector de punteros es un doble puntero, por lo que esa parte se lleva a cabo mediante
Con esas lineas creamos la cajita de la matriz y las cajitas de los punteros (el arreglo que figura en vertical).
Nos falta crear cada una de las n
filas, pero esto ya lo haremos pensando en una función:
dmatrix_create: función que crea una matriz de doubles dinámicamente.. casi | |
---|---|
¿Qué hacemos si falla la creación de la i-ésima fila?
Tenemos que liberar todo el espacio pedido hasta el momento (las i
filas y el vector) y devolver NULL
.
Para ello nos valdremos de una función auxiliar: dmatrix_free
:
dmatrix_free: función que libera la memoria pedida para una matriz de doubles | |
---|---|
Con esta función, podemos continuar con la implementación de la función anterior:
Arreglo dinámico de cadenas dinámicas#
Este ejemplo es similar al anterior, pero considerando que los elementos del vector ahora son cadenas, es decir, char *
en lugar de double *
.
Pero además, cada fila de la “matriz” tiene una cantidad de columnas distinta, ya que está dada por el largo de la
cadena.
Pensemos no que vamos a crear un arreglo de cadenas desde cero, sino que clonaremos un arreglo de cadenas en memoria
dinámica (el arreglo origen puede ser dinámico, o no).
Para ello, comenzaremos con la función strdup
:
Ahora sí, implementemos la función que clona los arreglos:
Ejercicio
La implementación de la función sarray_free
queda como tarea para el lector.
Clonando argv
#
Como vimos en la sección de argumentos en línea de comandos, argv
es un arreglo de cadenas, con la
particularidad de que su último elemento es NULL
.
Si quisiéramos aprovechar esa propiedad, deberíamos modificar sarray_clone
para que no utilice un n
sino ese puntero
a NULL
como fin del arreglo.
No obstante, ello nos obliga a utilizar realloc
, ya que a priori no sabemos cuánto espacio reservar.
Veamos cómo se hace:
Nota final#
El uso de memoria dinámica es el uso de una herramienta, es el uso de una API estándar para comunicarse con el sistema
operativo.
Es importante recordar que hay 4 funciones básicas en cuanto al uso de memoria dinámica, todas ellas en stdlib.h
:
void *malloc(size_t size);
,void free(void *ptr);
,void *calloc(size_t nmemb, size_t size);
, yvoid *realloc(void *ptr, size_t size);
.
Además:
- siempre se debe validar el valor devuelto por
malloc
,calloc
orealloc
, - siempre se debe liberar la memoria en cuanto deja de utilizarse,
- nunca debe asignarse el resultado de
realloc
en la misma variable que se le pasa como puntero.
Biblioteca estándar para manejo de argumentos#
Las funciones malloc
, free
, calloc
y realloc
se encuentran en la biblioteca stdlib.h
.
Guías de ejercicios#
La guía de ejercicios de memoria dinámica se encuentra aquí.