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.