miércoles, 2 de octubre de 2013

Sobre punteros y cajas de zapatos


Una característica esencial de la programación en C es la existencia de punteros. Recuerdo que la primera vez que me los explicaron me quedé con cara de “¿y esto para qué sirve?”.

Pongamos que la memoria de un ordenador se divide en celdas del mismo tamaño, como cajas de zapatos. Cada caja tiene una etiqueta que la identifica, pongamos las letras de la A a la Z (vamos a trabajar con un ordenador con muy poca memoria, pero para el caso será sufciente) y dentro de esas cajas se guardan los valores de las variables que vamos declarando.
(Aquí estamos haciendo una pequeña trampa. En un ordenador todo son números, de modo que decir que usamos letras para etiquetar las cajas es engañoso. En puridad las etiquetas de las cajas deberían numerarse con 1, 2, 3, etc. pero eso podría hacer algo difícil de entender las explicaciones que siguen. Por ejemplo, es menos lioso decir que en la dirección de memoria (caja de zapatos) “A” se almacena un 1, que decir que en la dirección de meoria 1 se almacena un 1. De modo que me voy a permitir la siguiente convención: para los números de las cajas de zapatos vamos a usar el sistema “abecedarial”, en el cual la A equivale al entero 1, la B al entero 2, etc. Es decir, A y 1 son la misma cosa, pero usaremos el sistema abecedarial para las cajas de zapatos y el natural para los valores que guardamos dentro de esas cajas.)
También, y en aras de la simplicidad, vamos a hacer que todas las cajas de zapatos sean del mismo tamaño, y que en ellas sólo vamos a guardar números enteros.
En tal caso, si en un programa “a la C” escribiéramos algo como esto:

int val;
val = 12;
val = val+2;;

En nuestra analogía lo que ocurriría al ejecutar el código es:
  1. Hemos declarado la variable “val”, por lo que necesitaremos asignarle a esta variable una de las cajas de zapatos en la que guardar los valores que le vayamos asignando. Por simplificar vamos a poner que el valor de “val” irá en la caja de zapatos con la etiqueta “A”.
  2. Le asignamos a val el valor 12, de modo que en la caja A, que es la que hemos asignado a val, metemos un 12.
  3. Si ahora fuéramos a la caja de zapatos con la etiqueta “A” encontraríamos un 14.

La manera de saber cuál es la dirección de memoria en la que se guarda el valor de “val” es por medio del operador “&”. Es decir, podemos decir que “&val” vale “A”.

Ahora vamos con los punteros. Los punteros son variables que almacenan la dirección de otra variable, es decir; las etiquetas con que identificamos las cajas de zapatos. En el lenguaje C si queremos declarar un puntero a un entero (es decir, una variable que almacene la dirección de memoria de un entero) debemos escribir;

int *pVal;

Ahora mismo se dice que el puntero pVal no apunta a ninguna parte, es decir, no le hemos dicho la dirección de memoria de qué variable queremos que almacene (sería el caso análogo a cuando declaramos una variable “normal” pero aún no le hemos asignado un valor). O dicho de otro modo, aún no hemos dicho a pVal a qué variable debe apuntar. Si queremos que pVal almacene la dirección de memoria de val, debemos escribir:

pVal = &val;

El operador “&” se llama operador “dirección” (“address” en inglés), y como hemos comentado antes, nos dice la dirección de memoria en la que se está almacenando una variable. Es decir, ahora el valor del puntero pVal es por tanto “A”.

Hay otro operador fundamental cuando trabajamos con punteros, el operador “*” o de “indirection” (me niego a escribir “indirección”... ¡ups!), y nos da el valor de la variable a la que apunta nuestro puntero. O en notación de caja de zapatos: el operador “*” nos dice el contenido de la caja de zapatos de la que nuestro puntero guarda la dirección. Es decir:

int val; // Los valores de val se van a guardar en la caja de zapatos “A”
val = 12; // En la caja de zapatos A metemos un doce
val = val+2; // Ahora hemos cambiado el contenido de la caja A a 14
int *pVal; // Declaramos un puntero a enteros
pVal = &val; // El valor de pVal ahora es “A”, la caja en la que guardamos el valor de “val”

Entonces ahora *pVal vale 14; lo que hay en la caja de zapatos con etiqueta “A” es un 14.

Aquí hay que tener en cuenta algo importante. El operador “*” funciona en ambos sentidos. Es decir, si ahora hacemos:

*pVal = 13;

Lo que hemos hecho es meter un 13 en la caja de zapatos a la que apunta pVal, que es la A, y por lo tanto ahora val valdrá 13. Es decir, por medio del puntero pVal hemos podido cambiar de manera indirecta el valor de la variable val.

Y ahora era cuando venía mi cara de “Todo estoy es muy interesante, pero ¿para qué sirve?”. Uno de los ejemplos clásicos del uso de punteros es el de la creación en C de una función “swap(val1,val2)” que simplemente intercambia el valor de las variables val1 y val2. Es decir, si val1 vale 2 y val2 vale 5 entonces swap(val1, val2) hace que val1 pase a valer 5 y val2 pase a valer 2. La manera más inmediata en que se nos ocurriría hacer esta función sería la siguiente:

#include <stdio.h>

void swap(int i, int j) {
   int tmp = i;
   i = j;
   j = tmp;
}
int main(){
   int val1 = 23;
   int val2 = 47;

   printf("Before. val1: %d, val2: %d\n", val1, val2);
   swap(val1, val2);
   printf("After.  val1: %d, val2: %d\n", val1, val2);

   return 0;
}
Sin embargo, si usáramos esta función para intercambiar el valor de dos variables, nos llevaríamos la sorpresa de que no habría pasado nada. El problema es que en C los argumentos se pasan a las funciones “por valor”. Esto significa que cuando llamemos a una función, se hacen copias de los argumentos, y dentro de la función se trabaja con esas copias.
En notación de cajas de zapatos; si usamos la variable val como argumento de una función, entonces se mirará el contenido de la caja “A”, y se hará una copia de eso en la caja “B” (o la primera caja que esté vacía). Dentro de la función, cada vez que se haga algo sobre val, “en realidad” se estará haciendo sobre el contenido de la caja “B”. De modo que cuando la función haya terminado, cuando queramos volver a comprobar el valor de val, veremos que no ha cambiado nada, ya que el contenido de la caja A no se ha modificado durante todas las operaciones que se han hecho dentro de la función.
Eso es exactamente lo que ocurre con la función “swap” escrita arriba; su lógica es correcta, pero el problema es que el intercambio de valores se hace entre las cajas de zapatos que no nos interesan en este caso.

Por supuesto, ahora viene la pregunta, ¿entonces cómo se definiría una función swap que funcionase? Bien, tenemos que pensar que lo que queremos cambiar es el contenido de las cajas en las que se guardan los valores de “val1 ” y “val2”, y para eso usaremos punteros. La idea de lo que debe hacer la función es:
Tomar las direcciones de las cajas en los que se guardan los valores de las variables val1 y val2, e intercambia los valores que hay en dichas cajas.

Es decir, lor argumentos de la función han de ser sendos punteros a las variables cuyos valores queremos intercambiar, hacer el intercambio del contenido de las cajas a las que hacen referencia, y ¡voi la!

#include <stdio.h>

void swap(int *i, int *j) {
   int tmp = *i;  
   *i = *j;
   *j = tmp;
}
int main(){
   int val1 = 23;
   int val2 = 47;
   int *p1 = &val1;
   int *p2 = &val2;

   printf("Before. val1: %d, val2: %d\n", val1, val2);
   swap(p1, p2);
   printf("After.  val1: %d, val2: %d\n", val1, val2);

   return 0;
}

Ahora todo funciona correctamente. Sin embargo el código anterior está un tanto “sobreactuado”. Si nos fijamos, no necesitamos definir los punteros p1 y p2, y podemos llamar a la función swap simplemente como swap(&val1, &val2), es decir, dando directamente como argumentos las etiquetas de las cajas de zapatos en las que se guardan los valores de las variables val1 y val2.