TEMA 4 : Punteros y funciones 4.0 Introducción En este tema estudiaremos el tipo de dato más importante dentro del lenguaje C. Los punteros. Absolutamente todos los datos en C pueden ser tratados como punteros y por ello este lenguaje proporciona una serie de importantes herramientas para trabajar con ellos. Además introduciremos el concepto de función asociado estrechamente a la llamada programación modular que nos permite crear un programa mucho más claro y fácil de corregir a la hora de encontrar errores. 4.1 Punteros 4.1.1 ¿ Qué son los punteros ? Como su nombre indica un puntero es algo que apunta, es decir, nos indica dónde se encuentra una cierta cosa. Supongamos (como otras tantas veces) que disponemos de un gran archivo en el que almacenamos informes. Este fichero está dividido en compartimientos, cada uno de los cuales contiene uno de nuestros informes (esto sería equivalente a las variables con las que hemos trabajado hasta ahora -informes-, la cuales contienen información, y el archivo representa la memoria de nuestro ordenador, obviamente las variables se almacenan en la memoria). Sin embargo otros compartimientos no contienen informes, sino que lo que contienen es una nota que nos dice dónde está ese informe. Supongamos que como máximo trabajamos con tres informes a la vez, digamos que no nos gusta leer demasiado, y reservamos, por tanto, tres compartimientos en los indicamos en que compartimiento se encuentran esos tres informes. Estos tres compartimientos serían nuestros punteros y como ocupan un compartimiento en el archivo (nuestra memoria) son realmente variables, pero variables muy especiales. Estas variables punteros ocupan siempre un tamaño fijo, simplemente contienen el número de compartimiento en el que se encuentra la información. No contienen la información en sí. Si en nuestro archivo pudiésemos almacenar un máximo de 20.000 hojas, esta sería la capacidad de nuestra memoria (unos 19 Kilobytes). Estas hojas de nuestros informes las agruparíamos de distintas formas. Quizá un informe sólo ocupe 5 páginas mientras que otro puede ocupar 100. Podemos ver esto como los distintos tipos de datos del C, es lógico pensar que necesitamos más espacio para almacenar un número real que uno entero o que una matriz de 20x20 elemento. Estos son nuestro informes en nuestro archivo. Sin embargo los punteros siempre ocupan lo mismo, en nuestro ejemplo nos llegaría con una página para poder escribir el número del compartimiento en el que se encuentra el inicio del informe. Así en nuestro supuesto de que sólo trabajemos con tres informes a la vez, dispondríamos de tres compartimientos en los que indicaríamos dónde se encuentran esos informes que buscamos y de esta forma cuando terminemos con ellos y deseemos trabajar con otros sólo tendremos que cambiar el contenido de esos tres compartimientos diciendo donde se encuentran los nuevos informes. De esta forma no es necesario reservar unos compartimientos para trabajar y cada vez que cambiemos de trabajo llevar los informes viejos a su compartimiento anterior y traer los nuevos informes a estos compartimientos. Esto es lo que en programación se conoce como referencia indirecta o indireción. Accedemos a la información a través de un puntero que nos dice dónde se encuentra ésta. Y a grandes rasgos ésto son los punteros, referencias indirectas a datos en la memoria del ordenador. Los punteros en C son muy importantes puesto que su utilización es básica para la realización de numerosas operaciones. Entre ellas: paso de parámetros que deseamos sean modificados, tratamiento de estructuras dinámicas de datos (ésto es, variables que no se declaran en el programa y se crean durante la ejecución del programa), cadenas de caracteres ... 4.1.2 Operadores que actúan sobre punteros. El lenguaje C proporciona dos operadores relacionados directamente con los punteros. El primero de ellos es el operador &. Ya hemos visto este operador antes en las llamadas a la función scanf, posteriormente explicaremos por que la función scanf necesita ser llamada con el operador &. El operador &, es un operador unario, es decir, actúa sobre un sólo operando. Este operando tiene que ser obligatoriamente una estructura direccionable, es decir, que se encuentre en la memoria del ordenador. Estas estructuras son fundamentalmente las variables y las funciones, de las que hablaremos posteriormente. Decimos que sólo se puede aplicar sobre estructuras direccionables porque su función es devolver la posición de memoria en la que se encuentra dicha estructura. En nuestro ejemplo nos indicaría cual sería el compartimiento en el que se encuentra el informe que le indiquemos. El segundo operador es el *. También se trata de un operador unario como el anterior y su función en este caso es la de permitir el acceso al contenido de la posición indicada por un puntero. En nuestro ejemplo el operador * nos permitiría leer o escribir el informe al que apunta uno de nuestros compartimientos punteros. Además el carácter * se utiliza para declarar punteros los cuales como ya dijimos tienen que ser declarados (tienen su propio compartimiento en el archivo). Por supuesto el operador * debe ser aplicado sobre un puntero, mientras que el operador & sobre una estructura direccionable (variable o función). Veamos un ejemplo de su utilización: main () { int x,y; /* Variables de tipo entero */ int *px; /* Puntero a una variable de tipo entero */ /* Leemos la dirección -compartimiento- de la variable -informe- x mediante & y lo almacenamos en la variable puntero px */ px = &x; /* px contiene la dirección en la que se encuentra x */ /* Utilizando el operador *, podemos acceder a su información. *px representa ahora el valor de la variable x */ *px = 10; /* Ahora x contiene el valor 10 */ y = 15; /* Si ahora hacemos que nuestro puntero apunte a la variable y utilizando de nuevo el operador & */ px = &y; /* El valor que ahora toma *px será el valor de y puesto que es el compartimiento al que ahora estamos apuntando */ *px = 125; /* Ahora y contiene el valor 125 */ x = *px /* Ahora x contiene también 125 */ } Como hemos visto en este ejemplo es exactamente igual acceder a una variable que utilizar un puntero que apunte a ella (hacemos que apunte a ella mediante el operador &) junto con el operador *. Pero el lenguaje C aún ofrece otra herramienta más para trabajar con punteros. Es lo que se suele llamar aritmética de punteros. Este tema lo trataremos en profundidad en el siguiente apartado. 4.1.3 Punteros y matrices Ya hemos hablado de las matrices en el tema anterior. Se trataba de un conjunto de un número de terminado de variables de un mismo tipo que se referenciaban con un nombre común seguido de su posición entre corchetes con relación al primer elemento. Todas las entradas de una matriz están consecutivas en memoria, por eso es muy sencillo acceder al elemento que queramos en cada momento simplemente indicando su posición. Sólo se le suma a la posición inicial ese índice que indicamos. Es un ejemplo que casa perfectamente con nuestro ejemplo de los informes, cada informe podría ser considerado como una matriz de tantos elementos como páginas tenga el informe y en los que cada uno de ellos es un tipo de datos llamado página. Las matrices son realmente punteros al inicio de una zona consecutiva de los elementos indicados en su declaración, por lo cual podemos acceder a la matriz utilizando los corchetes como ya vimos o utilizando el operador *. elemento[i] <=> *(elemento +i) Como ya se ha comentado todos los punteros ocupan lo mismo en memoria, el espacio suficiente para contener una dirección, sin embargo cuando se declaran es necesario indicar cual es el tipo de datos al que van a apuntar (entero, real, alguna estructura definida por el usuario). En nuestro ejemplo tendríamos un tipo de puntero por cada tipo de informe distinto, un puntero para informes de una página, otro puntero para informes de 2 páginas y así sucesivamente. En principio esto es irrelevante por que una dirección de memoria es una dirección de memoria, independientemente de lo que contenga con lo cual no sería necesario declarar ningún tipo, pero esta información es necesaria para implementar la aritmética de punteros que ejemplificaremos a continuación. Supongamos que hemos definido un tipo de datos en nuestro programa que fuese página, si cada página puede contener 80 caracteres de ancho por 25 de alto, podría ser algo como ésto: typedef char página[80][25]; Y supongamos también que sólo tenemos tres tipos de informes, de 1 página, de 5 páginas y de 25 páginas: typedef página informe1; typedef página informe2[5]; typedef página informe3[25]; Y en nuestro programa principal hemos declarado las siguientes variables: main() { página *punt_página; informe1 i1[10],*punt1; informe2 i3[5],*punt2; informe3 i4[15],*punt3; .... Por tanto disponemos de un puntero a páginas y tres punteros, uno para cada tipo de informe y tres matrices de distintos tipos de informes que nos permiten almacenar en nuestro archivo un máximo de 30 informes (10 de 1 página, 5 de 5 páginas y 15 de 25 páginas). Supongamos que en el programa principal se llenan esas matrices con datos (por teclado o leyendo de un fichero, por ejemplo) y realizamos las siguientes operaciones: punt_página = (página *) &i4[0]; punt3 = (informe3 *)&i4[0]; Los cast (que comentamos en el tema 1) convierten las direcciones al tipo apropiado, las direcciones que contendrán punt_página y punt3 serán exactamente iguales, apuntarán al principio del primer informe de tipo3. Sin embargo punt_página es un puntero de tipo página y punt3 es un puntero de tipo informe3, ¨qué significa ésto?. Si ejecutásemos una instrucción como ésta: punt_página = punt_página + 5; punt_página pasaría a apuntar a la quinta página del primer informe de tipo 3 (i4[0]), puesto que punt_página es un puntero de paginas. Mientras que si la operación fuese: punt3 = punt3 + 5; punt3 pasaría a apuntar a el quinto informe de tipo 3 (i4[5]), puesto que punt3 es un puntero a informes de tipo tres. Si ahora realizásemos la operación: punt_página = (página *)punt3; Ahora punt página apuntaría a la primera página del quinto informe de tipo 3. En esto consiste la aritmética de punteros, cuando se realiza una operación aritmética sobre un puntero las unidades de ésta son el tipo que se le ha asociado a dicho puntero. Si el puntero es de tipo página operamos con páginas, si es de tipo informes operamos con informes. Es evidente que un informe de tipo 3 y una página tienen distintos tamaños (un informe de tipo 3 son 25 páginas por definición). Como hemos visto las matrices se pueden considerar como punteros y las operaciones con esos punteros depende del tipo asociado al puntero, además es muy recomendable utilizar el cast cuando se realizan conversiones de un tipo de puntero a otro. 4.1.4 Punteros y cadenas de caracteres Como su propio nombre indica una cadena de caracteres es precisamente eso un conjunto consecutivo de caracteres. Como ya habíamos comentado los caracteres se codifican utilizando el código ASCII que asigna un número desde 0 hasta 255 a cada uno de los símbolos representables en nuestro ordenador. Las cadenas de caracteres utilizan el valor 0 ('\0') para indicar su final. A este tipo de codificación se le ha llamado alguna vez ASCIIZ (la Z es de zero). Las cadenas de caracteres se representan entre comillas dobles (") y los caracteres simples, como ya habíamos indicado con comillas simples ('). Puesto que son un conjunto consecutivo de caracteres la forma de definirlas es como una matriz de caracteres. char identificador[tamaño_de_la_cadena]; Y por ser en esencia una matriz todo lo comentado anteriormente para matrices y punteros puede ser aplicado a ellas. Así la siguiente definición constituye también una cadena de caracteres: char *identificador; La diferencia entre ambas declaraciones es que la primera reserva una zona de memoria de tamaño_de_la_cadena para almacenar el mensaje que deseemos mientras que la segunda sólo genera un puntero. La primer por tratarse de una matriz siempre tiene un puntero asociado al inicio del bloque del tamaño especificado. Podemos tratar a las cadenas como punteros a caracteres (char *) pero tenemos que recordar siempre que un puntero no contiene información sólo nos indica dónde se encuentra ésta, por tanto con la segunda definición no podríamos hacer gran cosa puesto que no tenemos memoria reservada para ninguna información. Veamos un ejemplo para comprender mejor la diferencia entra ambas declaraciones. Utilizaremos dos funciones especiales de stdio.h para trabajar con cadenas. Estas son puts y gets que definiríamos como un printf y un scanf exclusivo para cadenas. #include <stdio.h> main() { char cadena1[10]; char cadena2[10]; char *cadena; gets(cadena1); /* Leemos un texto por teclado y lo almacenamos en cadena 1 */ gets(cadena2); /* Idem cadena2 */ puts (cadena1); /* Lo mostramos en pantalla */ puts (cadena2); cadena = cadena1; /* cadena que sólo es un puntero ahora apunta a cadena1 en donde tenemos 10 caracteres reservados por la definición */ puts (cadena); /* Mostrara en pantalla el mensaje contenido en cadena1 */ cadena = cadena2; /* Ahora cadena apunta a la segunda matriz de caracteres */ gets(cadena); /* Cuando llenos sobre cadena ahora estamos leyendo sobre cadena2, debido al efecto de la instrucción anterior */ puts(cadena2); /* SI imprimimos ahora cadena2 la pantalla nos mostrará la cadena que acabamos de leer por teclado */ } En el programa vemos como utilizamos cadena que solamente es un puntero para apuntar a distintas zonas de memoria y utilizar cadena1 o cadena2 como destino de nuestras operaciones. Como podemos ver cuando cambiamos el valor de cadena a cadena1 o cadena2 no utilizamos el operador de dirección &, puesto que como ya hemos dicho una matriz es en sí un puntero (si sólo indicamos su nombre) y por tanto una matriz o cadena de caracteres sigue siendo un puntero, con lo cual los dos miembros de la igualdad son del mismo tipo y por tanto no hay ningún problema. 4.2 Funciones 4.2.1 Introducción Hasta el momento hemos utilizado ya numerosas funciones, como printf o scanf, las cuales forman parte de la librería estándar de entrada/salida (stdio.h). Sin embargo el lenguaje C nos permite definir nuestras propias funciones, es decir, podemos añadir al lenguaje tantos comandos como deseemos. Las funciones son básicas en el desarrollo de un programa cuyo tamaño sea considerable, puesto que en este tipo de programas es común que se repitan fragmentos de código, los cuales se pueden incluir en una función con el consiguiente ahorro de memoria. Por otra parte el uso de funciones divide un programa de gran tamaño en subprogramas más pequeños (las funciones), facilitando su comprensión, así como la corrección de errores. Cuando llamamos a una función desde nuestra función principal main() o desde otra función lo que estamos haciendo realmente es un salto o bifurcación al código que le hayamos asignado, en cierto modo es una forma de modificar el flujo de control del programa como lo hacíamos con los comandos while y for. 4.2.2 Definición de funciones Ya hemos visto cual es la estructura general de una función puesto que nuestro programa principal, main() no es otra cosa que una función. Veamos cual es el esquema genérico: tipo_a_devolver identificador (tipo1 parámetro1, tipo2 ...) { tipo1 Variable_Local1; tipo2 Variable_Local2; ... Código de la función return valor del tipo valor a devolver; } Lo primero con lo que nos encontramos es la cabecera de la función. Esta cabecera está formada por una serie de declaraciones. En primer lugar el tipo_a_devolver. Todas las funciones tienen la posibilidad de devolver un valor, aunque pueden no hacerlo. Si definimos una función que nos calcula el coseno de un cierto ángulo nos interesaría que nuestra función devolviese ese valor. Si por el contrario nuestra función realiza el proceso de borrar la pantalla no existiría ningún valor que nos interesase conocer sobre esa función. Si no se especifica ningún parámetro el compilador supondrá que nuestra función devuelve un valor entero (int). A continuación nos encontramos con el identificador de la función, es decir, el nombre con el que la vamos a referenciar en nuestro programas, seguido de una lista de parámetros entre paréntesis y separados por comas sobre los que actuará el código que escribamos para esa función. En el caso de la función coseno a la que antes aludíamos, el parámetro sería el ángulo calculamos el coseno de un cierto ángulo que en cada llamada a la función probablemente sea distinto. Véase la importancia de los parámetros, si no pudiésemos definir un parámetro para nuestra función coseno, tendríamos que definir una función para cada ángulo, en la que obviamente no indicaríamos ningún parámetro. A continuación nos encontramos el cuerpo de la función. En primer lugar declaramos las variables locales de esa función. Estas variables solamente podrán ser accedidas dentro de la función, esto es, entre las llaves ({}). Los nombres de las variables locales pueden ser los mismos en distintas funciones puesto que sólo son accesibles dentro de ellas. Así si estamos acostumbrados a utilizar una variable entera llamada i como contador en nuestro bucles, podemos definir en distintas funciones esta variable y utilizarla dentro de cada función sin que haya interferencias entre las distintas funciones. Con respecto al código de la función, pues simplemente se trata de un programa como todos los que hemos estado haciendo hasta ahora. La instrucción return del final puede omitirse si la función no devuelve ningún valor, su cometido es simplemente indicar que valor tomaría esa función con los parámetros que le hemos pasado. En otros lenguajes las funciones que no devuelven valores se conocen como procedimientos. Veamos un ejemplo de definición de una función. int busca_elemento (int *vector,int valor,int longitud) { int i; for (i=0;i<longitud;i++)
if (vector[i] == valor) break; return i; } Esta función busca un valor en un vector de números enteros y devuelve el índice dentro de la matriz de la entrada de ésta que lo contiene. Puesto que devuelve el índice de la matriz supondremos en principio un valor de retorno entero para ese índice. Los parámetros que debe conocer la función son: la matriz en la que buscar, el valor que debemos buscar y la longitud de la matriz. Podríamos haber realizado una función a medida para que utilizase una matriz de un número determinado de elementos (int vector[100], por ejemplo) y ahorrar el parámetro longitud, sin embargo con la definición que hemos hecho nuestra función funcionará con matrices de cualquier longitud de enteros. Hemos declarado además una variable local que es necesaria para la realización del bucle actuando como contador y conteniendo además el índice dentro de la matriz que buscamos. Si la entrada i de nuestro vector corresponde con valor, salimos del bucle y la variable i contiene ese valor de la entrada, el cual es devuelto por la función mediante la instrucción return. Ahora veremos como utilizaríamos esta función desde un programa: main () { int matriz1[20]; int matriz2[30]; int indice,dato; /* Aquí realizaríamos alguna operación sobre las matrices como por ejemplo inicializarlas */ indice = busca_elemento (matriz1,10,20); .... dato = 15; indice = busca_elemento (matriz2,dato,30); ..... } Como vemos en las llamadas a nuestra función podemos utilizar tanto variables o constantes como parámetros. 4.2.3 Más sobre funciones Cuando el valor que retornan las funciones no es entero, es necesario que el compilador sepa de antemano su tipo por lo cual es necesario añadir al comienzo del programa lo que se llaman prototipos. Los prototipos simplemente son una predeclaración de la función, solo indican el tipo que devuelve, su nombre y los tipos de los parámetros, no es necesario indicar un identificador para los parámetros. Un prototipo para la función anterior sería: int busca_elemento (int *, int, int); Los fichero .h que se incluyen con la directiva del procesador #include, contienen entre otras cosas los prototipos de las funciones a las que nos dan acceso. Para finalizar con las funciones vamos a explicar como pasar parámetros que deseamos que la función modifique. Cuando pasamos parámetros a una función ésta realiza una copia de los valores de éstos en una zona de memoria propia, con lo cual la función trabaja con estas copias de los valores y no hay peligro de que se modifique la variable original con la que llamamos a la función, forzando de esta forma a utilizar el valor retornado por la función como parámetro. Sin embargo es posible que nos interese que nuestra función nos devuelva más de una valor o que uno de los parámetros con los que lo llamamos se modifique en función de las operaciones realizadas por la función. En este caso tenemos que pasar los parámetros como punteros. Cuando pasamos los valores como punteros la función realiza una copia de los valores de los parámetros de las funciones en su zona propia de memoria, pero en este caso el valor que pasamos no es un valor en sí, sino que es una dirección de memoria en la que se encuentra ese valor que deseamos se modifique, es decir, creamos un puntero que apunta a la posición que deseamos modificar, con lo cual tenemos acceso a esos valores. Veamos un ejemplo típico de parámetros que deben modificarse, este es la función swap(a,b) cuya misión es intercambiar los valores de los dos parámetros, es decir, el parámetro a toma el valor del parámetro b y viceversa. La primera codificación que se nos ocurre sería ésta: swap (int a,int b) { int t; t = a; a = b; b = t; } Y nuestro programa principal podría ser algo como ésto: main () { int c,d; c = 5; d = 7; swap (c,d); } Veamos que pasa en la memoria de nuestro ordenador. -Función main() -Espacio para la variable c (Posición de memoria x) -Espacio para la variable d (Posición de memoria y) -Inicialización de las variables -swap(c,d) -Fin de main() -Función swap -Código de la función swap -Espacio privado para almacenar los parámetros (Posición de memoria z) En este último compartimiento es dónde almacenamos los valores de nuestros parámetros que serán respectivamente 5 y 7. Después de la ejecución de swap en esta zona de memoria los valores están intercambiados, nuestro parámetro a que se corresponde con la variable c en la llamada a swap contendrá el valor 7 y el parámetro b correspondiente a d en la función main contendrá el valor 5. Esto es lo que se encuentra almacenado en la zona privada de memoria de la función. Con este esquema cuando la función swap termina su ejecución y se devuelve el control al programa principal main, los valores de c y d no han cambiado, puesto que los compartimientos o posiciones de memoria x e y no han sido tocados por la función swap, la cual sólo ha actuado sobre el compartimiento z. Si declaramos ahora nuestra función swap como sigue: swap (int *p1,int *p2) { int t; t = *p1; /*Metemos en t el contenido de p1 */ *p1 = *p2; /* Contenido de p1 = contenido de p2 */ *p2 = t; } Tendremos el mismo esquema de nuestra memoria que antes pero en lugar de almacenar en la zona privada de la función swap para los parámetros los valores 5 y 7 tenemos almacenados en ella los compartimientos en los que se encuentran, ésto es, hemos almacenado las posiciones x e y en lugar de 5 y 7. De esta forma accedemos mediante un puntero a las variables c y d del programa principal que se encuentran en las posiciones x e y modificándolas directamente así que al regresar al programa principal sus valores se encuentran ahora intercambiados. En resumen, cuando deseemos que una función modifique el valor de uno de los parámetros con los que es llamada debemos pasar un puntero a esa variable en lugar del valor de esa variable. Es evidente que si implementamos nuestra función de esta forma, los parámetros jamás podrán ser constantes, puesto que difícilmente podríamos modificar el valor de una constante.