Punteros; esos desconocidos
Antes de explicar qué son, para qué sirven y cómo
hacer uso de ellos, vamos primero a exponer unas cuantas
consideraciones:
Los punteros no están disponibles en todos los lenguajes de
programación, ello significa que si, por ejemplo, programais
en C, podeis utilizarlos, pero si programais en Basic no.
Su uso produce unas ciertas ventajas sobre los programas, y es
que aumentan la flexibilidad a la hora de, por ejemplo, declarar
vectores o matrices cuyo tamaño no sea predefinido. No acaban
ahí las ventajas, con estructuras de datos más complejas
son el pan nuestro de cada día, pero de todo eso ya hablaremos.
Sin embargo, no todo es maravilloso, y tienen serios inconvenientes
que provocan más de un quebradero de cabeza. Es muy fácil
equivocarse usándolos y con ello escribir en alguna zona de
memoria no aconsejable (como las reservadas por el sistema
operativo), lo que provoca el consiguiente cuelgue en sistemas
poco robustos que permitan escribir en cualquier parte. No hay
nada más peligroso que un puntero incontrolado o sin
inicializar.
Vistas las pegas (ya las descubrireis cuando el programa se
os cuelgue sin motivo aparente), vamos a ver qué es eso de
los punteros, pues parece que tenemos que tenerles un cierto
respeto :-D
Qué es un puntero
Intuitivamente, un puntero es una flecha que apunta a alguna
parte. ¿A qué parte? Obviamente, si estamos hablando de
ordenadores, apuntará a una cierta dirección de memoria.
Es decir, un puntero es una representación simbólica de
una dirección de memoria.
(Nota importante: voy a usar la notación de C para los
punteros)
Veamos un ejemplo; supongamos que tenemos declarada la variable
Variable_Misteriosa en nuestro programa. Si queremos saber
cuál es la dirección de dicha variable, pondremos lo
siguiente:
&Variable_Misteriosa
{ el símbolo & precediendo a una variable es el
operador dirección de memoria }
y si, por ejemplo, escribimos en nuestro programa:
Mostrar_por_Pantalla(&Variable_Misteriosa)
saldrá en pantalla (por decir algo):
56743
que no es más que la celdilla de la memoria en la que se almacena
el valor que nosotros identificamos con Variable_Misteriosa.
Es decir, si hemos hecho previamente (suponiendo que fuera una
variable entera):
Variable_Misteriosa <- 9
lo que significa es que en la dirección de memoria 56743 está
almacenado el valor 9. ¿Está todo claro hasta aquí?
Bien, pues sigamos :)
En el caso que he puesto como ejemplo, lo que teníamos era
una *constante* puntero. Pero también podemos declarar como
variables, variables de tipo puntero. Estas variables contendrán,
como hemos dicho, una dirección de memoria.
Por ejemplo, si tenemos una variable puntero que se llame
Mi_Puntero, y una variable normal que se llame Mi_Variable,
podemos hacer cosas como esta:
Mi_Puntero <- &Mi_Variable
con lo que en Mi_Puntero tenemos almacenada la dirección de
memoria de Mi_Variable (y decimos que Mi_Puntero APUNTA a
Mi_Variable). Y aquí surge el primer problema. Si hacemos:
Mi_Puntero <- Mi_Variable
en Mi_Puntero está almacenada, como dirección de memoria,
el valor de la variable Mi_Variable. Si luego hemos de
escribir algo en la zona de memoria a la que apunta
Mi_Puntero, ya la hemos liado, puesto que no sabemos qué
puede haber ahí.
Resumiendo: no es lo mismo la dirección de memoria de una
variable que el contenido de la variable (o, lo que es
lo mismo, el contenido de esa dirección de memoria).
Seguimos. Como hemos dicho que Mi_Puntero es una variable
de tipo puntero, podemos hacer, más adelante en el curso
del programa, que apunte a otra variable de la misma
forma.
Ya sabemos que Mi_Puntero apunta a Mi_Variable. En este
caso, podemos utilizar el operador de indirección * (también
es de C; no confundirlo con el operador de multiplicación) para
encontrar el valor almacenado en Mi_Variable. ¿Cómo?
Escribiendo (por ejemplo):
Mi_Puntero <- &Mi_Variable
Mostrar_por_Pantalla(*Mi_Puntero)
es exactamente lo mismo que hacer:
Mostrar_por_Pantalla(Mi_Variable)
Lo que hemos hecho ha sido apuntar a Mi_Variable con Mi_Puntero
(es decir, Mi_Puntero contiene la dirección de memoria de
Mi_Variable) y después, con el operador *, mostrar el
CONTENIDO
de lo que hay en la dirección de memoria que guarda Mi_Puntero.
Es decir, lo que hace el operador de indirección es, seguido
de un puntero, dar el valor almacenado en la dirección de
memoria a la que apunta el puntero.
Sé que todo esto es un trabalenguas de cuidado, así que
releed con cuidado la primera parte del texto y seguidle
bien la pista. Haced algún dibujo si eso os ayuda.
Mientras, yo prosigo con el punto siguiente:
Declaración de punteros
He dicho antes que podemos tener variables de tipo puntero,
así que, lo lógico es querer saber cómo declararlas,
y a eso es a lo que vamos.
Cuando declárabamos (hace ya mucho tiempo) variables de
tipo entero, poníamos:
a,b,c : ENTEROS
¿No podríamos poner para los punteros algo como:?
a,b,c: PUNTEROS
Bien, pues la respuesta es NO. ¿Y por qué no? Pues porque
para declarar un puntero necesitamos saber, aparte de que
va a apuntar a alguien, a qué TIPO de alguien va a apuntar,
es decir, a qué tipo de variable va a apuntar. No será
lo mismo apuntar a un entero que a un caracter o que
a un real, pues estos tipos ocupan distintos tamaños en
memoria, y eso es algo fundamental para otra cosa que
veremos más adelante, la aritmética de punteros.
Para declarar una variable de tipo puntero a un tipo de
dato, lo haremos como sigue:
*Puntero1 : CARACTER
*Puntero2 : ENTERO
*Puntero3, *Puntero4 : REAL
(esto en C lo haríamos así:
char *Puntero1; int *Puntero2; float *Puntero3, *Puntero4 ;)
y tenemos que Puntero1 será una variable puntero que apunte a
una variable de tipo caracter, Puntero2 apuntará a una
variable de tipo entero y Puntero3 y Puntero4 apuntarán a variables
de tipo real.
Ahora que los tenemos declarados, suponiendo que tengamos las
variables Var1 de tipo caracter, Var2 de tipo entero y Var3
de tipo real, podemos inicializarlos haciendo lo que ya hemos
visto:
Puntero1 <- &Var1
Puntero2 <- &Var2
Puntero3 <- &Var3
y acceder a sus contenidos escribiendo:
*Puntero1
*Puntero2
*Puntero3
sin embargo, si se nos ocurre usar en el programa:
*Puntero4
puede pasar de todo. ¿Por qué? Pues porque este
puntero no está inicializado (lo he dejado a drede). Eso
significa que,
en principio, puede contener cualquier cosa. Al ser un puntero,
esa ''cualquier cosa'' será interpretada como una
dirección de memoria, y el contenido de ''cualquier dirección de
memoria'' puede ser de lo más insólito, lo que no es muy
recomendado si, por ejemplo, estamos haciendo cálculos.
Una cosa que no hay que perder de vista es que los punteros,
al ser variables, tienen una posición de memoria en la que
se guarda su contenido. Es decir, si hacemos:
&Puntero1
estamos accediendo a la dirección de memoria del puntero Puntero1.
Y el contenido de esa dirección de memoria es la dirección de
memoria de la variable a la que apunta.
Aritmética de Punteros
Trabajando con punteros, ¿qué sentido tiene hacer:?
Puntero <- Puntero + 1
Pues eso depende de a qué tipo de variable esté apuntando
la variable Puntero. Si Puntero apunta a un carácter,
como un carácter ocupa 1 byte en memoria, al hacer la
operación anterior, Puntero está apuntando al byte siguiente
al que apuntaba antes. Si la operación que hacemos es:
Puntero <- Puntero - 1
lo que hace es apuntar al byte anterior. Sin embargo, si
Puntero apunta a un real, como un real ocupa en memoria
10 bytes, en este caso apunta a los 10 bytes anteriores
al que estaba apuntando.
En otras palabras, cuando sumamos 1 a un puntero, no estamos
diciéndole que apunte a la dirección siguiente, sino que
pase a apuntar a la siguiente celdilla de memoria de acuerdo
con el tipo base al que apunta.
Y si en vez de sumar (o restar) 1, sumamos (o restamos) N,
avanzamos N veces lo que ocupe el tipo de variable al que
estamos apuntando.
Además, también podemos sumar y restar punteros. Veamos unos
ejemplos para aclararlo. Supongamos que Puntero1 y Puntero2
son punteros a enteros, y hacemos:
Puntero2 <- Puntero1 + 4
entonces, Puntero2 apunta a la posición de memoria 8 bytes
posterior a la que apunta Puntero1. Y si hacemos:
Puntero2 <- Puntero1 - 1
Puntero2 apunta a la posición de memoria 2 bytes anterior
a la que apunta Puntero1. También podemos hacer:
i <- Puntero2 - Puntero1
Esto normalmente se hace dentro de un mismo array, para saber
cuántos elementos los separan. Notar que el resultado que
da no es en bytes, sino en las mismas unidades que el
tamaño del tipo del array. Este resultado debe ser
asignado a una variable de tipo entero (la variable i
que aparece en el ejemplo se presupone previamente declarada).
¿Y todo esto para qué sirve?
Hablar de punteros por hablar puede ser muy entretenido si no
se tiene nada mejor que hacer, pero resulta que yo he dicho
que son muy útiles y no lo he dicho gratuitamente.
Por ejemplo, ahora que sabemos lo que son los punteros, vamos
a ver que están relacionados con el paso de parámetros en las
funciones, con lo que hablaremos de paso de variables por
valor y por referencia.
En su día hablamos de funciones y de procedimientos, y dijimos
que, en el caso de las funciones, pasábamos una serie de
variables que no eran modificadas, y se nos devolvía un
único valor. Por contra, en los procedimientos pasábamos una
serie de variables que podían ser modificadas pero, a cambio,
no se nos devolvía ningún valor.
Esto es así en lenguajes como el Pascal, pero en C esto no
sucede. Quiero decir, que en C no tenemos funciones y
procedimientos, sino que sólo tenemos
funciones. Las funciones
siguen teniendo la característica de que devuelven un
único valor, y que las variables que le pasamos como
argumentos no pueden ser modificadas. ¿Y si necesito que
se me devuelvan dos valores? ¿Cómo lo hago, si el C
sólo me permite funciones?
Pues lo hago con lo que se llama ''paso de parámetros por
referencia''.
En primer lugar, he de decir que en C, cuando pasamos
variables a las funciones, lo que hacemos es un ''paso de
parámetros por valor''. Es decir, la función recibe los
valores de las variables, pero no sabe nada más de ellas.
Cuando hacemos un paso por referencia, lo que estamos
pasando a la función no es el valor de la variable, sino
la dirección de memoria. Y la función, al tener la
dirección de memoria de la variable, ya sí puede modificarla,
pudiendo, en cierto sentido, ''devolvernos'' varios
valores, solucionando el problema que teníamos al no
disponer de procedimientos.
Voy a poner un ejemplo de esto comentado para que se
entienda la estructura y el por qué. Y para ello, voy
a utilizar un ejercicio que ya propuse, el de escribir
un procedimiento que intercambie el valor de dos variables.
El algoritmo de este procedimiento es algo tan sencillo
como esto:
PROCEDIMIENTO Intercambia(X,Y: ENTEROS)
variables
auxiliar: ENTERO
inicio
auxiliar <- X
X <- Y
Y <- auxiliar
fin
y lo podemos llamar desde cualquier punto del programa
principal sin más que poner
LLAMAR_A Intercambia(Un_Valor,Y_Otro_Valor)
Sin embargo, como ya he comentado, en C no tenemos procedimientos,
¿cómo salvamos este escollo?. Pues lo salvamos con una
función como la siguiente:
void Intercambia(int *x,int *y) {
int aux;
aux=*x; *x=*y; *y=aux;
}
y llamamos a esta función desde el programa principal como
sigue:
int main(void) {
int a=2,b=3;
printf("\nPrimero, a=%d y b=%d\n",a,b);
Intercambia(&a,&b);
printf("\npero ahora, a=%d y b=%d\n",a,b);
return 0;
}
Veamos cosas: para empezar, al llamar a la función
Intercambia, no
le hemos pasado los valores de las variables (paso por valor),
sino que le hemos pasado las direcciones de las variables
(paso por
referencia). Así pues, los parámetros formales de la
función Intercambia están recibiendo direcciones
de memoria, por lo que deben declararse como punteros, cosa que hemos hecho en la
cabecera de la función, indicando que recibe dos punteros.
Ahora veamos la función: en primer lugar, creo la variable
auxiliar necesaria para hacer el cambio. Sin embargo, en
vez de hacer:
aux <- x
hago:
aux <- *x
es decir, asigno a aux el contenido de la dirección de memoria
a la que apunta x. Como, al llamar la función, hemos pasado
unas direcciones de memoria, *x da como resultado el contenido
de la dirección de memoria donde se guarda la variable a.
Si hubiera hecho:
aux <- x
en aux tendré almacenada la posición de memoria de la variable a
y no su contenido, que es lo que yo quiero.
A continuación hacemos:
*x <- *y
es decir, al contenido de la posición de memoria a la que
apunta x se le asigna el contenido de la posición de memoria
a la que apunta y.
Y ya termina el intercambio con:
*y <- aux
con lo que al contenido de la posición de memoria a la que
apunta y se le asigna el valor de la variable aux, que era
el contenido de la posición de memoria a la que apuntaba x
originalmente, en otras palabras, el valor de la variable a.
Si tras leer con calma este punto un par de veces os perdeis,
me dejais una nota con las líneas que os parezcan más
oscuras, porque esto es un buen trabalenguas mental O:)
La otra gran utilidad es la reserva dinámica de memoria.
Si quereis, puedo dar aquí unos pequeños esbozos, pero eso ya
quedaría para otro capítulo. Creo que con 400 líneas
sobre punteros, para empezar, ya teneis bastante O:)