Programación (PRG) PRÁCTICA 9. Estructuras de datos Facultad de Informática Departamento de Sistemas Informáticos y Computación Universidad Politécnica de Valencia Curso 2002/2003 1. Introducción El objetivo de esta práctica es aprender a utilizar y a implementar algunas de las estructuras de datos lineales estudiadas en clase. Para ello, se implementarán en C una serie de estructuras, utilizando memoria dinámica. Una de las dificultades que habitualmente se encuentra cuando se está aprendiendo C es la utilización de los punteros. En esta práctica se mostrarán las operaciones básicas de manejo de punteros y memoria dinámica. Las estructuras de datos de un programa son tan importantes como las instrucciones que las utilizan. Seleccionar inicialmente una buena estructura de datos es crucial para solucionar un problema de manera eficiente. 2. 2.1. Punteros Punteros en C Un puntero es una dirección de memoria. Una variable de tipo puntero es una variable que almacena una dirección. Las variables puntero tienen asociado un tipo, que indica el tipo de datos de la zona de memoria donde apunta. Gráficamente, los punteros se suelen representar por flechas, que parten de la variable de tipo puntero, y llegan a la zona de memoria a donde apuntan. La Figura 1 muestra un ejemplo de una variable puntero a entero. Cuando se trabaja con punteros se ha de tener en mente que hay dos zonas de memoria implicadas. Una es la que contiene la dirección de memoria a la que apunta el puntero (la variable p, que contiene el valor 0x0012F en la Figura 1). La otra es la zona de memoria que empieza en la dirección que indica el puntero (la zona de memoria que empieza en 0x0012F). Para declarar la variable p de la figura, se utiliza la siguiente sintaxis: 1 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Memoria del ordenador p 0x0012F 0x0012F 123 Figura 1: Representación gráfica de un puntero a entero int *p; Para declarar punteros a otro tipo de datos, únicamente habrı́a que modificar el tipo que aparece a la izquierda del *: double *a; float *b; char *c,*d; struct cliente *e; Nótese que, si se desea declarar dos o más variables de tipo puntero en la misma lı́nea, habrá que anteponer siempre el asterisco al nombre de cada variable. Todas las variables puntero, independientemente del tipo de datos al que apunten, ocupan 4 bytes en la arquitectura Intel Pentium. El tamaño de la zona a la que apuntan viene dado por el tipo de datos asociado, por ejemplo, un puntero a double apunta a una zona de 8 bytes. En C se puede definir un tipo puntero genérico como void *, pero no se puede trabajar con ellos directamente, ya que el tamaño de la zona de memoria a la que apuntan es indeterminado. Justo después de declarar una variable de tipo puntero, su valor está indeterminado, por lo que no resulta aconsejable ni leer ni escribir en la zona de memoria a la que apunta. Se dice que el puntero no está inicializado, o que es un puntero loco. Gráficamente, se puede representar como se muestra en la Figura 2. Si se intenta leer o escribir en la zona de memoria donde apunta un puntero no inicializado, en la mayorı́a de las ocasiones el sistema operativo Página 2 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Figura 2: Representación gráfica de un puntero no inicializado intervendrá, terminando el programa y mostrando un mensaje del tipo: Segmentation fault ó Violación de segmento Cero patatero. Acceder a un puntero no inicializado es la falta más grave en la que se puede incurrir trabajando con punteros. En la cabecera stdio.h está definida la constante NULL. Dicha constante es una dirección de memoria especial, que se suele utilizar para inicializar los punteros a un valor conocido (aunque no accesible). Posteriormente se explicará la utilidad de dicha constante. Cuando se asigna a un puntero el valor NULL, del siguiente modo: p=NULL; se dice que p es un puntero nulo. A todos los efectos, intentar leer o escribir donde apunta un puntero nulo es equivalente a acceder a donde apunta un puntero no inicializado. 2.2. Operadores de punteros Cuando se trabaja con punteros se pueden utilizar dos operadores especiales: uno para calcular la dirección en memoria de cualquier variable, y otro para leer o escribir en la dirección a la que apunta un puntero. El operador & (léase ampersand ) devuelve la dirección de la variable a la que precede. En el siguiente ejemplo: int x, *p; x=100; p = &x; p contendrá a partir de ese momento la dirección de memoria asociada a la variable x. Una vez que se tiene una variable puntero, para acceder al valor apuntado por ella, se usará el operador *. Siguiendo el ejemplo anterior: Página 3 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos if (*p==x) printf("iguales\n"); else printf("distintos\n"); En la Figura 3 se muestran gráficamente las distintas operaciones que se pueden hacer sobre variables de tipo puntero. double *p,q=7,*r; p=&q; p r p r 7.0 q 7.0 q *p=10; p r 10.0 q r=p; p r 10.0 q p=NULL; p r 10.0 q Figura 3: Operaciones con punteros Ejercicio. Escribe un programa en el que se declaren 3 variables de tipo float y una variable tipo puntero. Asigna a las variables float los valores 3.0, 6.0 y 9.0. Imprime el valor de cada una de ellas por pantalla, pero utilizando el puntero, es decir, tendrás que utilizar 3 llamadas como la siguiente: printf("%f\n",*p); ¿Cuándo * y cuándo &? Siempre que te surjan dudas al utilizar los operadores de punteros, recuerda que, en cualquier asignación, el tipo de datos de la expresión que aparece a la derecha del signo = debe coincidir con el tipo de datos de la variable que aparece a la izquierda. Por ejemplo: double a, *b; [...] a=b; // MAL! Como a es de tipo double, lo que aparece a la derecha debe ser double. Para convertir un puntero a double en un double, hay que escribir un * delante: a=*b; De esta forma, se le está asignando a a el valor al que apunta b. Página 4 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Ejercicio. Indica si las siguientes expresiones son sintácticamente correctas. Explica lo que hace en el caso de que sea correcta, y corrige la expresión en caso de que sea incorrecta. int *a,b,*c; a=b; a=c; b=a; 2.3. Memoria dinámica Hasta el momento, la única forma que se ha visto para hacer que un puntero apunte a una zona de memoria válida, es hacer que apunte a la zona de memoria de otra variable. Todas las variables que se han declarado hasta ahora se dice que son estáticas, porque es el compilador el que calcula la memoria que necesitan. Además, en cuanto se invoca a la función que contiene la variable, se le reserva memoria automáticamente en el registro de activación correspondiente. Hay una forma de posponer la asignación de memoria. Mediante el uso de memoria dinámica, se puede reservar espacio para una variable en tiempo de ejecución. Dicha zona de memoria podrá tener cualquier tamaño, y no es necesario conocerlo en tiempo de compilación. La zona de memoria dinámica de un programa C se debe ver como una zona, separada de la pila de registros de activación, en la que se puede reservar y liberar segmentos de memoria (véase la Figura 4). Zona de memoria dinámica (montículo o heap) Pila de registros de activación func2(a: ) aux= func1(a: , b: ) main() res= Figura 4: Pila de registros de activación y heap Para el manejo de la memoria dinámica de C hay principalmente tres Página 5 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos funciones, definidas en el fichero de cabecera stdlib.h. void *malloc(size_t size); void *calloc(size_t nmemb, size_t size); void free(void *ptr); La función malloc reserva una zona de memoria de size bytes (el tipo size_t es un sinónimo de unsigned long). Para calcular el tamaño en bytes de cualquier tipo (o variable), se puede utilizar el operador sizeof. Por ejemplo: tam=sizeof(double); La llamada a malloc devuelve, en caso de que haya memoria disponible, un puntero a la primera posición de la zona recién reservada, en forma de un puntero de tipo void. Para convertirlo al tipo con el que se desea trabajar, habrá que utilizar un cast. En el caso de que no haya memoria libre suficiente, la llamada a malloc devolverá el valor NULL. Por ello, después de llamar a malloc, siempre habrá que comprobar que ha devuelto un puntero válido. En el siguiente ejemplo se reserva espacio para una variable de tipo double, se le asigna un valor y se imprime por pantalla: double *pd; pd=(double *)malloc(sizeof(double)); if (pd==NULL) exit(-1); *pd=34.5; printf("%lf",*pd); La zona de memoria recién creada por malloc no se inicializa, y por lo tanto contendrá un valor indefinido. Si se desea que la zona de memoria recién creada se escriba con ceros, se debe llamar a la función calloc, que se estudiará más adelante. Una vez que se ha terminado de utilizar una zona de memoria dinámica es necesario liberarla, para que pueda ser utilizada posteriormente. La función que se encarga de liberar zonas de memoria reservadas con malloc es free, y únicamente hay que pasarle el puntero a liberar: free(pd); La función free no pone el puntero que libera a nulo. En la Figura 5 se muestra gráficamente los distintos estados por los que pasa un puntero trabajando con memoria dinámica. Página 6 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Figura 5: Etapas en la vida de un puntero 2.4. Punteros y vectores La forma general de declarar un vector en C es la siguiente: tipo nombre_variable[tama~ no]; Hay dos formas principales de declarar un vector, por ejemplo, de caracteres: char a[10]; char *a; La primera forma tiene reservada una zona de memoria de tamaño 10 caracteres. La segunda declara un puntero sin inicializar. En C, apenas hay distinción entre los vectores y los punteros. De hecho, si se utiliza el nombre del vector sin corchetes, se tiene un puntero al primer elemento del mismo. Cuando se pasa un vector a una función, en realidad lo que se está pasando es un puntero al primer elemento. Por ello, si se modifica un elemento del vector dentro de la función, cuando acabe la misma, el vector original se habrá modificado. Hay una tercera forma de declarar un vector sin dar las dimensiones explı́citamente, pero enumerando los datos del mismo. De esta forma los vectores se pueden inicializar en tiempo de compilación: char a[]="Esto es una cadena"; int b[]={1,2,3,4,5}; En ambos casos, el compilador calculará el tamaño exacto necesario para cada vector. Si se desea reservar espacio para un vector en tiempo de Página 7 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos ejecución, se puede utilizar la función malloc vista anteriormente: char *a; int *b; a=(char *)malloc(sizeof(char)*10); if (a==NULL) return -1; /* Comprobación de que hay espacio */ b=(int *)malloc(sizeof(int)*5); if (b==NULL) return -1; En el ejemplo se está reservando espacio para un vector de 10 caracteres, y otro de 5 enteros. A la hora de reservar espacio para cadenas de caracteres, hay que recordar que el carácter de fin de cadena (ASCII 0) también se debe almacenar. Ejercicio. Haz un programa que muestre por pantalla una serie de números, de la forma que se indica a continuación: 10 32 87 93 1 10 32 87 93 10 32 87 10 32 10 Es el usuario el que debe indicar la cantidad de números a mostrar. Los números se generarán aleatoriamente entre 1 y 100. Utiliza para ello un vector del tamaño exacto que indique el usuario. Para declarar una matriz bidimensional, se dispone de las siguientes posibilidades: char a[3][4]; char *b[3]; char **c; /* Tiene toda la memoria asignada */ /* Tiene parte de la memoria asignada */ /* No tiene memoria asignada */ Para reservar memoria a la matriz c, se deben seguir los siguientes pasos: c=(char **)malloc(sizeof(char *)*3); c[0]=(char *)malloc(sizeof(char)*4); c[1]=(char *)malloc(sizeof(char)*4); c[2]=(char *)malloc(sizeof(char)*4); Como se ha dicho antes, el uso de vectores y punteros es intercambiable. Por lo tanto, se puede acceder a los elementos de un vector por medio de Página 8 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 a DSIC PRÁCTICA 9. Estructuras de datos 0,0 0,1 0,2 0,3 1,0 1,1 1,2 1,3 2,0 2,1 2,2 2,3 c b Figura 6: Representación de matrices bidimensionales punteros. De igual forma, se puede indexar una variable que se ha declarado como puntero. Ası́, para recorrer un vector de 10 elementos, se puede hacer: int a[10],i; for (i=0;i<10;i++) printf("%d ",a[i]); o bien, int a[10],i,*p; p=a; for (i=0;i<10;i++) { printf("%d ",*p); p++; } En el caso de matrices bidimensionales: d[1][2] ≡ *(d[1]+2) ≡ *(*(d+1)+2) En C se permite aritmética de punteros (incrementos, decrementos, sumas y restas), y se hacen con respecto al tipo al que apunte el puntero. Por ejemplo: char *a; int *b; double *c; Página 9 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos a++; /* Apunta al siguiente byte en memoria (sizeof(char)=1)*/ b++; /* Apunta cuatro bytes más allá (sizeof(int)=4) */ c=c+10; /* Apunta 80 bytes más allá (sizeof(double)=8) */ Por último, si se desea que los elementos del vector estén inicializados a cero, se deberá utilizar la función calloc para reservar memoria, del siguiente modo: int *vector; vector=(int *)calloc(10,sizeof(int)); if (vector==NULL) exit(-1); [...] free(vector); 2.5. Punteros y estructuras Las estructuras permiten agrupar un conjunto de variables de distinto tipo bajo una misma entidad. Son equivalentes a los registros de Pascal. La sintaxis de la definición de las estructuras es la siguiente: struct [nombre_estructura] { tipo nombre_campo; tipo nombre_campo; ... } [variables_de_estructura]; Los dos ejemplos siguientes son equivalentes. Definen el tipo estructura y declaran una variable de ese tipo estructura, y otra de tipo puntero a estructura: struct s1 { char *c1; int c2; float c3[10]; }; struct s1 v1,*v2; struct { char *c1; int c2; float c3[10]; } v1,*v2; Mientras que en el primer ejemplo se ha declarado un nuevo tipo utilizable en el resto del programa (struct s1), en el segundo, al no darle un Página 10 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos nombre al tipo, sólo se podrán utilizar las dos variables declaradas. Para acceder a los campos de una variable tipo estructura, se usan los operadores . o -> (si la variable es de tipo estructura, o bien de tipo puntero a estructura, respectivamente). Para los ejemplos anteriores: v1.c2 v1.c3[3] v2->c2 v2->c3[3] /* también se puede usar (*v2).c2 */ /* (*v2).c3[3] */ ¡Cuidado!. Al declarar una variable puntero a estructura, inicialmente apunta a una zona de memoria no válida, como pasa con cualquier puntero. De la misma forma que antes, hay que hacer que apunte a una zona válida, tomando la dirección de otra variable tipo estructura, o bien reservándole espacio con malloc. 2.6. Paso de parámetros a funciones El paso de parámetros a funciones en C siempre se realiza por valor, es decir, las funciones reciben copias de los valores originales. De esta forma, cuando una función cambia el valor de uno de sus argumentos, la variable que originalmente tenı́a el valor continúa intacta. Por ejemplo: void machaca(int a) { a=3; } ... int entero; entero=0; machaca(entero); entero=? La llamada a la función machaca crea una copia temporal de la variable entero, que es la que se pasa a la función. A todos los efectos, los argumentos de una función se pueden ver como variables locales (ver Figura 7). Por ello, cuando una función acaba, se eliminan de la memoria tanto las variables locales como los argumentos, junto a los valores que tuvieran en ese momento. De esta forma, C ofrece tres posibilidades para sacar información de una función: Página 11 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos 0 entero 0 void machaca(int a) { a=3; } Figura 7: No se modifica la variable original Variables globales (no aconsejada). Valor de retorno de la función. Argumentos pasados por referencia. En el caso de querer devolver más de un dato, no se podrá utilizar el valor de retorno de la función. Para devolver más de un valor, se pueden utilizar parámetros pasados por referencia. Pasar un argumento por referencia implica que la función puede modificar el contenido de la variable original. Para ello, debe conocer dónde se encuentra en memoria. La solución es pasar a la función un puntero a la variable original (ver Figura 8). En el ejemplo anterior: entero 0 void machaca(int *a) { *a=3; } Figura 8: Sı́ se modifica la variable original void machaca(int *a) { *a=3; } ... int entero; entero=0; machaca(&entero); entero=3 Página 12 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Ejercicio. Implementa una función que, dado el radio de un cı́rculo, devuelva su diámetro, su área y su perı́metro. 3. Implementación de una lista A continuación se implementarán algunas operaciones de una lista. La lista está compuesta por nodos enlazados, sin centinela, como muestra la Figura 9. Lista l; Esto es una lista Figura 9: Esquema de una lista de palabras A continuación se muestra la definición del tipo de datos Nodo, y del tipo de datos Lista: typedef struct t_nodo { char *info; struct t_nodo *sig; } Nodo; typedef Nodo *Lista; La operación más común que se realiza con las estructuras de datos de tipo lista suele ser el recorrido. Para recorrer una lista, casi siempre se sigue el siguiente esquema (se asume que el puntero al primer elemento de la lista se almacena en la variable lst: Nodo *aux; aux=lst; /* aux apunta al primer nodo de la lista */ while (aux!=NULL) /* mientras que aux sea válido...*/ { /* PROCESAR EL NODO aux */ aux=aux->sig; /* aux apunta al siguiente nodo */ } Dentro del while habrá que procesar cada nodo mediante el puntero aux. Por ejemplo, para contar el número de caracteres total almacenados en la lista, se podrı́a utilizar la siguiente función: Página 13 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos int CuentaCaracteres(Lista l) { Nodo *aux; int contador; contador=0; aux=l; while (aux!=NULL) { contador+=strlen(aux->info); aux=aux->sig; } return contador; } Ejercicio. El esquema anterior, ¿funciona en todos los casos? ¿Funciona en el caso de que la lista esté vacı́a? Justifica la respuesta. A continuación se muestran las funciones a implementar: Constructores Lista ListaVacia(void); int InsertarPrimero(Lista *l, char *palabra); int InsertarUltimo(Lista *l,char *palabra); Consulta int EstaVacia(Lista l); void MostrarLista(Lista l); int LongitudLista(Lista l); int ConsultarPalabra(Lista l, int pos, char *palabra); Borrado int BorrarPrimero(Lista *l); int BorrarUltimo(Lista *l); 3.1. Constructores Una vez que se hayan implementado las funciones anteriores, se podrá construir una lista del siguiente modo: Lista *lst; lst=ListaVacia(); InsertarPrimero(&lst, "pepito"); InsertarPrimero(&lst, "juanito"); InsertarPrimero(&lst, "andres"); Página 14 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Ejercicio. Construye una librerı́a para la implementación de listas de palabras. Para ello, crea dos ficheros, llamados lista_pal.c y lista_pal.h. En el primero introducirás el código para el manejo de listas, tal y como lo vayas desarrollando (los apartados siguientes te guiarán). En lista_pal.h escribe la definición de los tipos Nodo y Lista vistos en el Apartado 3, y las cabeceras de las funciones de la librerı́a. El siguiente programa te permitirá probar la librerı́a; escrı́belo en un fichero llamado prueba_lista.c: #include "lista_pal.h" int main(void) { Lista lst; lst=ListaVacia(); InsertarUltimo(&lst,"es"); InsertarUltimo(&lst,"una"); InsertarUltimo(&lst,"prueba"); InsertarPrimero(&lst,"esto"); printf("Lista: \n"); MostrarLista(lst); return 0; } Para compilar el programa, una vez que tengas la librerı́a o, al menos, las funciones que se utilizan, debes ejecutar: gcc -o prueba_lista prueba_lista.c lista_pal.c -Wall 3.1.1. ListaVacia La cabecera de la función es: Lista ListaVacia(void); Esta función devuelve una lista vacı́a. Una lista vacı́a se representa por medio del puntero nulo. 3.1.2. InsertarPrimero La cabecera es: Página 15 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos int InsertarPrimero(Lista *l, char *palabra); Esta función inserta una copia del elemento apuntado por palabra en la primera posición de la lista. En el caso de que pueda acabar la operación, la función devolverá cero. En caso de error, devuelve -1. Para ayudar a la inserción de elementos en una lista, implementa la siguiente función: Nodo *CrearNodo(char *palabra); Dicha función construye un nodo que contiene la palabra que recibe en el parámetro. Para ello: 1. Reserva espacio para un nodo. 2. Crea una copia de la cadena que se le pasa (en el argumento palabra) mediante la función strdup. Dicha función está definida en string.h, y su cabecera es: char *strdup(char *s). 3. Almacena la copia en el campo info de la estructura creada. 4. Pon el campo sig del nodo a nulo. 5. Devuelve el nodo. Para insertar un elemento en la primera posición de una lista, hay que seguir los pasos mostrados en la Figura 10: 1. Crear un nuevo nodo con la función CrearNodo. 2. Hacer que el campo sig del nuevo nodo apunte al elemento que estaba en la primera posición. 3. Hacer que la cabeza de la lista sea el nuevo nodo. lst 3 aux 2 1 Figura 10: Pasos para insertar un elemento en cabeza Página 16 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 3.1.3. DSIC PRÁCTICA 9. Estructuras de datos InsertarUltimo La cabecera de la función es: int InsertarUltimo(Lista *l,char *palabra); Esta función debe encontrar el último elemento de la lista, crear un nuevo nodo con la palabra correspondiente (con la función CrearNodo) y hacer que el siguiente al último elemento sea el nuevo nodo. Devuelve cero si ha podido realizar la operación, y -1 en caso de error. 3.2. Consulta Las funciones que se van a construir a continuación permiten acceder a la información albergada en la lista. 3.2.1. EstaVacia La cabecera de la función es: int EstaVacia(Lista l); Devuelve un cero en el caso de que la lista tenga algún elemento, y un uno en caso contrario. 3.2.2. MostrarLista Este procedimiento muestra por pantalla el contenido de la lista. La cabecera es la que aparece a continuación: void MostrarLista(Lista l); Ejercicio. Cuando hayas implementado las funciones anteriores ya puedes resolver el ejercicio de la página 15. Una vez que hayas probado que las funciones implementadas funcionan, continúa implementando las funciones siguientes y modificando el programa principal para probarlas. 3.2.3. LongitudLista Esta función devuelve el número de palabras de la lista. La cabecera de la función es: int LongitudLista(Lista l); Página 17 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 3.2.4. DSIC PRÁCTICA 9. Estructuras de datos ConsultarPalabra Esta función permite recuperar las palabras almacenadas en los nodos de la lista. Para ello, se deberá indicar la posición del nodo a consultar, y una variable de tipo cadena donde copiar la palabra. El perfil de la función es el siguiente: int ConsultarPalabra(Lista l, int pos, char *palabra); Si la posición existe, y la función puede copiar el resultado en el argumento palabra, entonces devolverá un cero. Si la palabra no existe, devolverá un -1. Por ejemplo, una llamada a esta función para recuperar la tercera palabra de la lista serı́a la siguiente: char resultado[200]; Lista lst; [...] if (ConsultarPalabra(lst,3, resultado)) printf("No existe esa palabra\n"); else printf("La palabra de la posición 3 es %s\n",resultado); [...] 3.3. Borrado Por último, las funciones que se presentan a continuación permitirán eliminar los elementos de la lista. 3.3.1. BorrarPrimero Esta función borra el primer nodo de la lista. El perfil de la función es el siguiente: void BorrarPrimero(Lista *l); En la Figura 11 se pueden ver los pasos para desenlazar el primer nodo de la lista. Después de actualizar los punteros, sólo queda liberar el campo info del nodo, y luego el propio nodo. En el caso de que la lista sólo tuviera un elemento, ésta deberá quedar vacı́a. Página 18 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos 2 aux 1 Figura 11: Pasos para borrar el elemento en cabeza 3.4. BorrarUltimo Esta función borra el último nodo de la lista. El perfil de la función es el siguiente: void BorrarUltimo(Lista *l); En el caso de que la lista sólo tuviera un elemento, ésta se deberá quedar vacı́a. 4. Implementación de una pila Una vez que se tienen implementadas las operaciones de manejo de listas, es muy sencillo implementar una pila a partir de dichas operaciones. Una pila es una estructura de almacenamiento que únicamente permite el acceso al último elemento apilado. En la realidad hay múltiples ejemplos de pilas. Por ejemplo, una serie de cajas colocadas una encima de otra son una pila. Sólo se puede acceder a una caja cuando no tiene ninguna otra encima, y sólo se puede añadir una caja encima de todas las demás (no se puede poner una caja debajo de otra). Las dos operaciones principales de manejo de pilas son la de apilar (push en inglés) y desapilar (pop). A continuación se muestra el fichero de cabecera pila.h, donde se indican las funciones que se deben implementar en otro fichero, que se llamará pila.c: #include "lista.h" typedef Lista Pila; Pila PilaVacia(); int EstaPilaVacia(Pila p); int Apilar(Pila *p, char *palabra); int Desapilar(Pila *p); int ConsultarCima(Pila p, char *palabra); Página 19 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos En el fichero anterior se puede ver la definición del tipo de datos Pila. Dicho tipo se ha definido como un sinónimo de Lista, por lo que se podrán utilizar las operaciones implementadas anteriormente. Una pila vacı́a se representará exactamente igual que una lista vacı́a. A continuación se enumeran las operaciones que debes implementar. 4.1. PilaVacia Esta función devuelve una pila vacı́a. La cabecera de la función es: Pila PilaVacia(); 4.2. EstaPilaVacia Esta función devuelve un uno si la pila está vacı́a, y un cero en otro caso. El perfil es: int EstaPilaVacia(Pila p); 4.3. Apilar Esta función permite insertar elementos en la cima de la pila. Se le debe pasar la palabra a insertar. El perfil de la función es el siguiente: int Apilar(Pila *p, char *palabra); Si la llamada se puede completar, la función devuelve cero y si ha ocurrido un error (por ejemplo, si no hay memoria suficiente), devolverá -1. 4.4. Desapilar Esta función saca de la pila el elemento que está en la cima. Si únicamente habı́a un elemento en la pila, la pila quedará vacı́a. La cabecera de la función es: int Desapilar(Pila *p); Devuelve cero si ha podido sacar el elemento de la cima, y -1 si la pila estaba vacı́a. 4.5. ConsultarCima Por último, esta función devuelve una copia de la palabra que está en la cima de la pila. Una llamada a esta función no modifica el estado de la pila, por lo que el elemento que estaba en la cima sigue estando allı́. La cabecera de la función es la siguiente: Página 20 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos int ConsultarCima(Pila p, char *palabra); Devuelve cero en caso de que haya podido devolver la palabra, y -1 si la pila estaba vacı́a. Ejercicio. Una vez que hayas implementado las funciones anteriores, haz un programa que las utilice para probar que funcionan correctamente. 5. Ejercicios propuestos 1. Construye las funciones int BorrarElemento(Lista *l, int pos) y void BorrarLista(Lista *l) que borran el elemento de la posición pos, y todos los elementos de la lista, respectivamente. En el caso de que en la primera función se borre el último elemento de la lista, ésta deberá quedar vacı́a. La segunda función siempre deja la lista vacı́a. 2. Escribe un programa que lea desde teclado (o desde la entrada estándar) una serie de frases, hasta encontrar un carácter EOF. Luego las deberá escribir por pantalla, pero empezando por la última frase leı́da y acabando por la primera. 3. Implementa una cola de palabras utilizando las funciones de la lista implementadas en la práctica. Las funciones a implementar se llaman: ColaVacia, EstaColaVacia, Encolar, Desencolar y CosultarCabeza A. Compilación de programas con múltiples ficheros fuente Uno de los principios de la programación modular es dividir un problema complejo en subproblemas más sencillos, que se puedan implementar más fácilmente por separado. Esta división se puede hacer tanto a nivel de subrutinas (procedimientos y funciones) como de módulos (grupos de subrutinas). Los compiladores de C ayudan a implementar este tipo de diseño permitiendo que un programa ejecutable proceda de uno o más ficheros fuente. Como se comentó en la Práctica 3, es posible compilar un programa de este tipo con una orden como: gcc -o prg fich1.c fich2.c fich3.c El inconveniente de esta forma de compilación es que siempre se compilan todos los ficheros, aunque no se hayan modificado. En el ejemplo anterior, si el programador modifica el fichero fich3.c y ese cambio no afecta al resto de ficheros, éstos no tendrı́an por qué compilarse de nuevo. Para poder compilar únicamente los ficheros modificados, se pueden utilizar los ficheros objeto, Página 21 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos que son ficheros que contienen el código compilado de un archivo fuente. Los ficheros objeto no son directamente ejecutables, ya que normalmente no tienen una función main. Las siguientes órdenes generan, respectivamente, los ficheros fich1.o, fich2.o y fich3.o: gcc -c fich1.c gcc -c fich2.c gcc -c fich3.c Para generar el programa final habrı́a que ejecutar: gcc -o prg fich1.o fich2.o fich3.o Dicha orden lo que hace es enlazar el código objeto de cada fichero y las librerı́as de ejecución necesarias para construir el archivo ejecutable, que se llamará prg. Si el programador modifica el fichero fich3.c, pero ese cambio no afecta a los demás ficheros, para construir el programa podrá ejecutar la orden: gcc -o prg fich1.o fich2.o fich3.c Dicha orden compilará únicamente el código del fichero fich3.c y utilizará el código objeto de los otros dos ficheros para construir el programa ejecutable final. El comando make está pensado para facilitar el mantenimiento y regeneración de grupos de programas. Para su utilización, el programador debe crear un fichero, que normalmente se llama makefile, en el que se definen las dependencias entre unos ficheros y otros. Las dependencias indican qué ficheros se deben recompilar si cambia uno dado. Si, por ejemplo hay dos ficheros independientes entre sı́, y se modifica uno de ellos, el otro no tiene por qué recompilarse. Por ello, cada vez que se ejecuta el comando make se examina el fichero makefile para determinar las dependencias entre los ficheros que forman el programa. Con esta información, el comando make recompilará aquellos ficheros fuente que han sido modificados con posterioridad a la generación de su código objeto y, a su vez, los que dependen de éstos. La utilización del comando make tiene, pues, dos consecuencias: en cada momento, únicamente se recompila lo que es estrictamente necesario y se evita el error (muy frecuente) de olvidarse de recompilar algún fichero fuente que haya sido modificado o que dependa de alguno que haya sido modificado, además de ejecutar la compilación en el orden correcto. Página 22 de 23 Prácticas PRG. Facultad de Informática Curso 2002/2003 DSIC PRÁCTICA 9. Estructuras de datos Como ejemplo, supongamos que tenemos creado el fichero makefile, cuyo contenido es: prg: modulo.o prg.o gcc -o prg prg.o modulo.o modulo.o: modulo.c modulo.h gcc -c modulo.c prg.o: prg.c modulo.h gcc -c prg.c Nótese que las lı́neas que indican las órdenes a ejecutar (en el ejemplo anterior, las que empiezan por gcc) deben comenzar con un tabulador. Cuando el usuario ejecuta el comando make, se genera el ejecutable prg, que depende de los ficheros modulo.o y prg.o. Si existen y están actualizados dichos ficheros objeto, entonces se ejecuta la orden: gcc -o prg prg.o modulo.o Si no existen los ficheros objeto, o no están actualizados, se compilarán teniendo en cuenta el resto del fichero makefile. Por ejemplo, el fichero modulo.o depende de sus ficheros fuente (modulo.c y modulo.h) y por lo tanto, si se han modificado o no existe modulo.o, se ejecutará la orden correspondiente para generarlo. Si, como es normal, el programa principal incluye el fichero de cabecera modulo.h para conocer el perfil de las funciones exportadas por modulo.c, entonces se debe hacer explı́cita esa dependencia, como muestra el fichero makefile anterior. Por lo tanto, si se modifica el fichero modulo.h, la próxima vez que se ejecute el comando make, se recompilarán todos los ficheros. Se puede definir una variable nom_var dentro de un fichero makefile y después utilizar su contenido con $(nom_var). Por ejemplo, el fichero anterior se podrı́a escribir: nom = prg $(nom): modulo.o $(nom).o gcc -o $(nom) $(nom).o modulo.o modulo.o: modulo.c modulo.h gcc -c modulo.c $(nom).o: $(nom).c modulo.h gcc -c $(nom).c Página 23 de 23