TEMA 5 : Asignación dinámica de memoria 5.0 Introducción En este tema estudiaremos las posibilidades que ofrece el lenguaje C a la hora de trabajar dinámicamente con la memoria dentro de nuestros programas, esto es, reservar y liberar bloques de memoria dentro de un programa. Además en este tema se introducirá el concepto de tipo abstracto de dato y la forma de dividir un gran programa en otros más pequeños. 5.1 Asignación dinámica y estática de memoria. Hasta este momento solamente hemos realizado asignaciones estáticas del programa, y más concretamente estas asignaciones estáticas no eran otras que las declaraciones de variables en nuestro programa. Cuando declaramos una variable se reserva la memoria suficiente para contener la información que debe almacenar. Esta memoria permanece asignada a la variable hasta que termine la ejecución del programa (función main). Realmente las variables locales de las funciones se crean cuando éstas son llamadas pero nosotros no tenemos control sobre esa memoria, el compilador genera el código para esta operación automáticamente. En este sentido las variables locales están asociadas a asignaciones de memoria dinámicas, puesto que se crean y destruyen durante la ejecución del programa. Así entendemos por asignaciones de memoria dinámica, aquellas que son creadas por nuestro programa mientras se están ejecutando y que por tanto, cuya gestión debe ser realizada por el programador. 5.2 ¿Cómo se reserva memoria dinámicamente? El lenguaje C dispone, como ya indicamos con anterioridad, de una serie de librerías de funciones estándar. El fichero de cabeceras stdlib.h contiene las declaraciones de dos funciones que nos permiten reservar memoria, así como otra función que nos permite liberarla. 5.2.1 Reserva de memoria Las dos funciones que nos permiten reservar memoria son: malloc (cantidad_de_memoria); calloc (número_de_elementos, tamaño_de_cada_elemento); Estas dos funciones reservan la memoria especificada y nos devuelven un puntero a la zona en cuestión. Si no se ha podido reservar el tamaño de la memoria especificado devuelve un puntero con el valor 0 o NULL. El tipo del puntero es, en principio void, es decir, un puntero a cualquier cosa. Por tanto, a la hora de ejecutar estás funciones es aconsejable realizar una operación cast (de conversión de tipo) de cara a la utilización de la aritmética de punteros a la que aludíamos anteriormente. Los compiladores modernos suelen realizar esta conversión automáticamente. Antes de indicar como deben utilizarse las susodichas funciones tenemos que comentar el operador sizeof. Este operador es imprescindible a la hora de realizar programas portables, es decir, programas que puedan ejecutarse en cualquier máquina que disponga de un compilador de C. El operador sizeof(tipo_de_dato), nos devuelve el tamaño que ocupa en memoria un cierto tipo de dato, de esta manera, podemos escribir programas independientes del tamaño de los datos y de la longitud de palabra de la máquina. En resumen si no utilizamos este operador en conjunción con las conversiones de tipo cast probablemente nuestro programa sólo funciones en el ordenador sobre el que lo hemos programado. Por ejemplo, el los sistemas PC, la memoria está orientada a bytes y un entero ocupa 2 posiciones de memoria, sin embargo puede que en otro sistema la máquina esté orientada a palabras (conjuntos de 2 bytes, aunque en general una máquina orientada a palabras también puede acceder a bytes) y por tanto el tamaño de un entero sería de 1 posición de memoria, suponiendo que ambas máquinas definan la misma precisión para este tipo. Con todo lo mencionado anteriormente veamos un ejemplo de un programa que reserva dinámicamente memoria para algún dato. #include <stdlib.h> #include <stdio.h> main() { int *p_int; float *mat; p_int = (int *) malloc(sizeof(int)); mat = (float *)calloc(20,sizeof(float)); if ((p_int==NULL)||(mat==NULL)) { printf ("\nNo hay memoria"); exit(1); } /* Aquí irían las operaciones sobre los datos */ /* Aquí iría el código que libera la memoria */ } Este programa declara dos variables que son punteros a un entero y a un float. A estos punteros se le asigna una zona de memoria, para el primero se reserva memoria para almacenar una variable entera y en el segundo se crea una matriz de veinte elementos cada uno de ellos un float. Obsérvese el uso de los operadores cast para modificar el tipo del puntero devuelto por malloc y calloc, así como la utilización del operador sizeof. Como se puede observar no resulta rentable la declaración de una variable simple (un entero, por ejemplo, como en el programa anterior) dinámicamente, en primer lugar por que aunque la variable sólo se utilice en una pequeña parte del programa, compensa tener menos memoria (2 bytes para un entero) que incluir todo el código de llamada a malloc y comprobación de que la asignación fue correcta (ésto seguro que ocupa más de dos bytes). En segundo lugar tenemos que trabajar con un puntero con lo cual el programa ya aparece un poco más engorroso puesto que para las lecturas y asignaciones de las variables tenemos que utilizar el operador *. Para termina un breve comentario sobre las funciones anteriormente descritas. Básicamente da lo mismo utilizar malloc y calloc para reservar memoria es equivalente: mat = (float *)calloc (20,sizeof(float)); mat = (float *)malloc (20*sizeof(float)); La diferencia fundamental es que, a la hora de definir matrices dinámicas calloc es mucho más claro y además inicializa todos los elementos de la matriz a cero. Nótese también que puesto que las matrices se referencian como un puntero la asignación dinámica de una matriz nos permite acceder a sus elementos con instrucciones de la forma: NOTA: En realidad existen algunas diferencias al trabajar sobre máquinas con alineamiento de palabras. mat[0] = 5; mat[2] = mat[1]*mat[6]/67; Con lo cual el comentario sobre lo engorroso que resultaba trabajar con un puntero a una variable simple, en el caso de las matrices dinámicas no existe diferencia alguna con una declaración normal de matrices. 5.2.2 Liberación de la memoria. La función que nos permite liberar la memoria asignada con malloc y calloc es free(puntero), donde puntero es el puntero devuelto por malloc o calloc. En nuestro ejemplo anterior, podemos ahora escribir el código etiquetado como : /* Ahora iría el código que libera la memoria */ free (p_int); free(mat); Hay que tener cuidado a la hora de liberar la memoria. Tenemos que liberar todos los bloque que hemos asignado, con lo cual siempre debemos tener almacenados los punteros al principio de la zona que reservamos. Si mientras actuamos sobre los datos modificamos el valor del puntero al inicio de la zona reservada, la función free probablemente no podrá liberar el bloque de memoria. 5.2.3 Ventajas de la asignación dinámica. Vamos a exponer un ejemplos en el que se aprecie claramente la utilidad de la asignación dinámica de memoria. Supongamos que deseamos programar una serie de funciones para trabajar con matrices. Una primera solución sería definir una estructura de datos matriz, compuesta por una matriz y sus dimensiones puesto que nos interesa que nuestras funciones trabajen con matrices de cualquier tamaño. Por tanto la matriz dentro de la estructura tendrá el tamaño máximo de todas las matrices con las que queramos trabajar y como tenemos almacenadas las dimensiones trabajaremos con una matriz de cualquier tamaño pero tendremos reservada memoria para una matriz de tamaño máximo. Estamos desperdiciando memoria. Una definición de este tipo sería: typedef struct { float mat[1000][1000]; int ancho,alto; } MATRIZ; En principio esta es la única forma de definir nuestro tipo de datos. Con esta definición una matriz 3x3 ocupará 1000x1000 floats al igual que una matriz 50x50. Sin embargo podemos asignar memoria dinámicamente a la matriz y reservar sólo el tamaño que nos hace falta. La estructura sería ésta. struct mat { float *datos; int ancho,alto; }; typedef struct mat *MATRIZ; El tipo MATRIZ ahora debe ser un puntero puesto que tenemos que reservar memoria para la estructura que contiene la matriz en sí y las dimensiones. Una función que nos crease una matriz sería algo así: MATRIZ inic_matriz (int x,int y) { MATRIZ temp; temp = (MATRIZ) malloc (sizeof(struct mat)); temp->ancho = x; temp->alto = y; temp->datos = (float *) malloc (sizeof(float)*x*y); return temp; } En esta función se ha obviado el código que comprueba si la asignación ha sido correcta. Veamos como se organizan los datos con estas estructuras. temp------->datos---------->x*x elementos ancho,alto Esta estructura puede parecer en principio demasiado compleja, pero veremos en el siguiente capítulo que es muy útil en el encapsulado de los datos. En nuestro programa principal, para utilizar la matriz declararíamos algo así: main() { MATRIZ a; a = inic_matriz (3,3); ... borrar_matriz(a); } Dónde borrar_matriz(a) libera la memoria reservada en inic_matriz, teniendo en cuenta que se realizaron dos asignaciones, una para la estructura mat y otra para la matriz en sí. Otra definición posible del problema podría ser así. typedef struct mat MATRIZ; void inic_matriz (MATRIZ *p,int x,int y) { p->ancho = x; p->alto = y; p->datos = (float *)malloc(sizeof(float)*x*y); } Con este esquema el programa principal sería algo como ésto: main() { MATRIZ a; inic_matriz (&a,3,3); ..... borrar_matriz (&a); } Con este esquema el acceso a la matriz sería *(a.datos+x+y*a.ancho), idéntico al anterior sustituyendo los puntos por flechas ->. En el siguiente capítulo se justificará la utilización de la primera forma de implementación frente a esta segunda. En realidad se trata de la misma implementación salvo que en la primera el tipo MATRIZ es un puntero a una estructura, por tanto es necesario primero reservar memoria para poder utilizar la estructura, mientras que en la segunda implementación, el tipo MATRIZ es ya en si la estructura, con lo cual el compilador ya reserva la memoria necesaria. En el primer ejemplo MATRIZ a; define a como un puntero a una estructura struct mat, mientras que en la segunda MATRIZ a; define a como una variable cuyo tipo es struct mat. 5.3 Tipos de datos abstractos y encapsulado. 5.3.0 Introducción En el apartado anterior se mostraba un ejemplo de la definición de un nuevo tipo de datos. En general un nuevo tipo de datos es simplemente una estructura que contiene información, la cual es organizada de una forma determinada dependiendo de cual será su aplicación. Podemos además de crear la estructura de datos asociar al nuevo tipo una serie de funciones que nos permitan actuar sobre esos datos, de forma que el usuario final que utilice nuestra estructura de datos no necesite saber como está organizada internamente. A grandes rasgos ésto es lo que se entiende por encapsulamiento de la información. El usuario dispone de un tipo de datos abstracto, puesto que no tiene acceso directo a esa información y por ello se hace necesaria la utilización de funciones que nos permitan procesar esa información. Esta organización tiene numerosas ventajas. En primer lugar la depuración de programas se hace más sencilla, puesto que si se produce un error mientras trabajamos con nuestro tipo abstracto, necesariamente ese error se produce dentro de las funciones asociadas a él y no en otro lugar. Además resulta sencilla la modificación de la implementación de las funciones o incluso de la estructura de datos asociada a nuestro tipo, sin necesidad de modificar los programas que utilizan nuestro tipo abstracto. Estamos aumentando la modularidad de nuestra programación. La definición de tipos abstractos es la base de la programación orientada a objetos. Este tipo de programación denomina los tipos abstractos como clases, una clase es una estructura de datos y una serie de funciones asociadas a esa estructura de datos. Además en general disponen de otras opciones tales como sobrecarga de operadores, herencia de clases, etc... En la actualidad el estándar en programación orientada a objetos es el C++. Otros lenguajes como el C o el Módula II no están orientados a objetos pero disponen de facilidades para la definición de tipos abstractos de datos. 5.3.1 Código fuente, código objeto. Librerías y enlazadores Los programas que hemos esta realizando hasta el momento se conocen como código fuente, son ficheros de texto cuyas líneas contienen instrucciones de un determinado lenguaje de programación. El ordenador sólo puede ejecutar un número determinado de instrucciones, en general muy sencillas, por ello necesitamos utilizar un compilador para poder ejecutar nuestros programas. El compilador traduce nuestro código fuente (un programa en C, por ejemplo) a un código objeto que la máquina puede reconocer. En principio este código tal y como es generado por el compilador no puede ser ejecutado por el sistema operativo, puesto que en nuestro código fuente hacemos referencia a funciones como printf o malloc que nosotros no hemos definido. Estas funciones se encuentran ya compiladas en forma de código objeto y agrupadas en lo que se conoce como una librería. Así se hace necesaria la utilización de otro programa conocido como enlazador o linker. El enlazador toma como entrada una serie de módulos objeto o librerías y enlaza todos los módulos utilizados por el programa en un sólo programa que pueda ser ejecutado por el sistema operativo. De todo esto se deduce que resulta muy interesante disponer de librerías para distintas operaciones que pueden ser utilizadas en cualquiera de nuestros programas. Además a medida que los programas se van haciendo cada vez más grandes es recomendable dividir el programa en varios ficheros que contengan, cada uno de ellos, funciones que guarden cierta relación o que actúen sobre un determinado tipo de datos, siendo ésta la finalidad que perseguimos en este capítulo. 5.3.2 Los ficheros de proyectos de Borland Los compiladores de lenguaje C de Borland ofrecen un entorno integrado para el desarrollo de programas. Nos ofrece un editor para escribir nuestro código fuente, un compilador para obtener el código objeto y un enlazador para construir el programa ejecutable final. NOTA: esto es sólo aplicable a compiladores C con entornos integrados. Por consiguiente, no es aplicable al GCC, pero conviene saber algo del tema para posteriores trabajos con otros compiladores. Aquí se hace referencia al compilador Borland C. Hasta ahora nuestros programas eran muy sencillos y ocupaban un sólo fichero, con lo que el enlazador sólo tenía que vérselas con las librerías del sistema. En este capítulo nuestros programas van a estar formados por varios ficheros y por ello tendremos que utilizar los ficheros de proyectos. Estos ficheros simplemente contienen los nombres de los ficheros que son necesarios para crear el programa final. Se pueden crear utilizando la opción Proyect del menú principal del compilador y seleccionando posteriormente la opción Open. Una ventana aparecerá en pantalla y en ella tendremos que introducir los nombres de los ficheros que compondrán nuestro programa. En la línea inferior de la pantalla siempre aparece una ayuda con las teclas que se pueden utilizar para trabajar en la ventana que se encuentre activa. La tecla INSERT nos permite seleccionar los ficheros a incluir mediante un selector de ficheros semejante al que aparece al cargar un programa. 5.3.4 Tipos abstractos de datos. Ya hemos explicado el concepto de tipo abstracto de datos en la introducción de este capítulo, y en esta sección nos centraremos en el modo de implementación. Fundamentalmente vamos a tener tres ficheros: 1.-El programa principal: Este programa utilizará el tipo de datos abstractos que implementaremos como un módulo independiente. Además debe incluir un fichero .H con las definiciones de los tipos abstractos y de las funciones de gestión de los datos. 2.-El fichero de cabecera conteniendo la definición del tipo abstracto y de los prototipos de las funciones. 3.-La estructura real del tipo de datos, la cual permanecerá oculta al programa principal, y el código de las funciones definidas para trabajar con el tipo. Nuestro tipo abstracto no es tal. En el fichero 3, el que implementa las funciones y tipos de datos, nos encontramos con una definición de un tipo de dato como podría ser el utilizado en el ejemplo de las matrices. Sin embargo en el programa principal no tendremos acceso a los distintos campos de esa estructura de datos, ésta es la razón de que se llamen tipos abstractos. La mejor forma para expresar un tipo abstracto es el tipo void. void representa cualquier cosa, de esta manera para el programa principal cualquier tipo abstracto será un puntero a void. La necesidad de que se trate con punteros, deriva del hecho de que tenemos que mantener las estructuras de datos ocultas, lo que nos obliga a asignar dinámicamente las variables del tipo abstracto, haciéndose necesario el uso de punteros. Veremos un ejemplo de implementación de un tipo abstracto de datos y lo comentaremos. El tipo que implementaremos será el tipo vector y definiremos una serie de funciones para el trabajo con vectores. Comenzaremos con el fichero que implementa el nuevo tipo de datos y las funciones asociadas a él. VECTOR.C #include<stdio.h> #include<stdlib.h> #define VECTOR_C struct vector { float *componentes; int dimension; }; typedef struct vector *VECTOR; #include "vector.h" VECTOR crear_vector(int dimension) { VECTOR temp; temp=(VECTOR)malloc(sizeof( struct vector)); if (temp==0) { printf("\nno hay sitio en memoria");exit(1); } temp->dimension=dimension; temp->componentes=(float *)calloc(dimension,sizeof(float)); if (temp->componentes==0) { printf("\nno hay sitio en memoria");exit(1); } return temp; } void borrar_vector(VECTOR vec) { free(vec->componentes); free(vec); } float leer_componente(VECTOR vec,int componente) { float x; x=*(vec->componentes+componente); return x ; } void escribir_componente(VECTOR vec,int componente,float valor) { *(vec->componentes+componente)=valor; } void suma(VECTOR uno,VECTOR dos,VECTOR tres) { int i; if (uno->dimension!=dos->dimension) printf("\nerror"); else if (uno->dimension!=tres->dimension) printf("\nerror"); else for(i=0;i<uno->dimension;i++) *(tres->componentes+i)=*(dos->componentes+i)+ *(uno->componentes+i); } En primer lugar se indican las librería que necesita utilizar el fichero en sí. En este caso la stdlib para las asignaciones y liberaciones de memoria, y la stdio para sacar mensajes de error por pantalla. A continuación definimos un tipo de datos para trabajar con vectores, se trata de un vector y un entero que nos indica la longitud, y finalmente el tipo de dato abstracto VECTOR como un puntero a esta estructura. Como vemos dentro del fichero VECTOR.C el dato VECTOR está perfectamente definido y la ocultación de éste se realiza en el fichero VECTOR.H. En el fichero VECTOR.C podemos dividir las funciones en varios tipos. En primer lugar están las funciones crear_vector y borrar_vector, las cuales se encargan de reservar y liberar la memoria necesaria para cada elemento. Por otra parte tenemos las funciones leer_componente y escribir_componente que nos permiten acceder a la estructura de datos, dependiendo del tipo de datos puede que no sea necesario que el programador acceda a los datos en la estructura. Finalmente tenemos las funciones que realizan operaciones con los vectores, sumar_vectores. Vemos que antes de que comience el código de las funciones tenemos un include de el fichero VECTOR.H, esta línea lo que hace es incorporar los prototipos de las funciones para poder llevar a cabo la compilación, ya se explicó la función de los prototipos de funciones. Además al principio de el fichero se ha definido un constante VECTOR_C, en el fichero .H veremos su utilidad. VECTOR.H #ifndef VECTOR_H #define VECTOR_H #ifndef VECTOR_C typedef void *VECTOR; #endif VECTOR crear_vector(int); void borrar_vector(VECTOR); float leer_componente(VECTOR,int); void escribir_componente(VECTOR,int,float); void suma(VECTOR,VECTOR,VECTOR); #endif En primer lugar se comprueba si se ha definido la constante VECTOR_H, para evitar incluir el fichero más de una vez. Si está definida se ignora el fichero, si no está definida la define y comprueba si el fichero que llama a VECTOR.H es VECTOR.C mediante la constante VECTOR_C que se definía al comienzo del fichero VECTOR.C. Si no está definida se declara el tipo abstracto VECTOR como un puntero a void, eliminando de esta forma cualquier información sobre la forma de el tipo asociado a VECTOR en VECTOR.C. Si está definida no se realiza la definición para evitar una redeclaración de VECTOR dentro de VECTOR.C. Finalmente se encuentran los prototipos de las funciones asociadas al tipo vector. Hasta el momento sólo conocíamos la directiva del preprocesador #include. En este ejemplo se incluye dos más, cuyo significado es evidente. La directiva #define permite definir una constante en general realiza una macro sustitución, veámoslo con unos ejemplos: #define PI 3.14159 Esta sentencia simplemente define una constante, en cualquier parte de el programa en la que aparezca PI, este símbolo se sustituirá por 3.14159. #define COMP vector->datos Con esta definición podríamos escribir instrucciones como: *(COMP+1) = 10; Las directivas #ifndef y #endif van siempre asociadas señalando bloques de programa y permiten la compilación condicional de bloques de programa. Significan algo como: Si no está definida tal cosa compila (#ifndef) esto Hasta aquí (#endif) Finalmente veamos como utilizaríamos nuestro tipo abstracto en un programa, para sumar dos vectores. #include"stdio.h" #include"vector.h" main() { VECTOR x,y,z; int i; x=crear_vector(3); y=crear_vector(3); z=crear_vector(3); printf ("\n"); for(i=0;i<3;i++) escribir_componente(x,i,i); for(i=0;i<3;i++) escribir_componente(y,i,i+2); suma(x,y,z); for(i=0;i<3;i++) { printf("\n%f + %f = %f",leer_componente(x,i), leer_componente(y,i),leer_componente(z,i)); } borrar_vector (x); borrar_vector (y); borrar_vector (z); }