Punteros ( 1era parte ).


Dentro de la memoria de la computadora cada dato almacenado ocupa una o más celdas contiguas de memoria. El número de celdas de memoria requeridas para almacenar un dato depende de su tipo.


Si 'v' es una variable que representa un determinado dato, el compilador automáticamente asigna celdas de memoria para ese dato dependiendo de su tipo. El dato puede ser accedido si conocemos su localización ( la dirección ) de la primera celda de memoria. La dirección de 'v' se determina mediante &v, donde & es un operador monario, llamado operador dirección, que proporciona la dirección del operando. Si la dirección de 'v' se le asigna a otra variable pv = &v; esta nueva variable es un puntero a 'v': representa la dirección de 'v' y no su valor. Los punteros son direcciones. El dato representado por 'v' puede ser entonces accedido mediante *pv, donde * es un operador monario, llamado operador indirección ( u operador de “derreferencia” ), que opera sólo sobre una variable puntero.


En C, los punteros son una herramienta fundamental para construir programas. Sin ellos se podría hacer realmente poca cosa. Examinemos unos ejemplos que muestran donde los punteros son necesarios:

1.- Paso de parámetros por referencia.

Ya hablamos de ésto en funciones.

2.- Declaración de arreglos dinámicos.

3.- Acceso a memoria física.

Declaración de punteros


Los punteros, como cualquier otra variable, deben ser declarados antes de ser usados. Cuando una variable puntero es definida, el nombre de la variable debe ir precedida por un *. Este identifica que la variable es un puntero. El tipo de dato que aparece en la declaración se refiere al tipo de objeto del puntero, el tipo de dato que se almacena en la dirección representada por el puntero, en vez del puntero mismo. Así, una declaración de puntero general es:


tipo_dato *ptvar;


donde ptvar es la variable puntero y tipo_dato el tipo de dato apuntado por el puntero.


Dentro de la declaración, una variable puntero puede ser inicializada asignándole la dirección de otra variable que por supuesto debe estar previamente declarada.


Los operadores de punteros.


Los operadores monarios & y * son miembros del mismo grupo de precedencia que los otros operadores monarios: -,++,--,!,sizeof y (tipo). Hay que recordar que este grupo de operadores tiene mayor precedencia que el de los operadores aritméticos y la asociatividad de los operadores monarios es de derecha a izquierda.


El operador dirección (&) sólo puede actuar sobre variables ordinarias o elementos simples de un arreglo.


El operador indirección (*) sólo puede actuar sobre operandos que sean punteros.


Las variables puntero pueden apuntar a variables numéricas o de caracteres, arreglos, funciones, otras variables puntero y también pueden apuntar a otros tipos de estructuras de datos más complejos. Por tanto a una variable puntero se le puede asignar la dirección de una variable ordinaria. También se le puede asignar la dirección de otra variable puntero, siempre que ambas apunten al mismo tipo de dato.


Mostremos lo que hemos hablado hasta ahora con un ejemplo:


#include<stdio.h>


main(){

int v1, v2;

int *pv;


v1 = 555; // Asigna 555 a v1

pv = &v1; // pv recibe la dirección de v1.

v2 = *pv; // Asigno a v2 el “contenido” de la posición de memoria pv.


printf(“%i”,v2); // Imprime 555;

return 0;

}


Esto hace algo como:

Direcciones

Contenido

0x00F0

v1 = 555

0x00F1

v1

0x00F2

v2 = 555

0x00F3

v2

...


0X####

pv = 0x00F0

0X####+1



donde la dirección '####' no es realmente de importancia.


Aquí se observa cómo los punteros contienen direcciones de memoria. 'v1' es una variable que contiene el valor de 555. 'pv' es un puntero ( almacenado en cualquier dirección #### que realmente no nos importa en este caso ) que contiene la dirección de memoria de 'v1', o sea, 0x00F0. Luego a 'v2' se le asigna el contenido de 'pv' ( El contenido de la localidad de memoria 0x00F0 que es 555 ). Por último el programa imprime la variable 'v2'. ¡¡¡ Fácil !!!, ¿ no es cierto ?, pues ese es el concepto detrás de los punteros.


OJO: Es muy peligroso trabajar con punteros si éstos no han sido asignados a ninguna posición de variable con anterioridad. Esto se ve bien ya que si no ha sido asignada una dirección, el puntero podría contener una dirección importante que ya está siendo usada y podría variar su valor. Para evitar este tipo de conflictos podríamos curarnos en salud asignándole a los punteros un valor predeterminado o inicializarlos a NULO ( NULL ). Esto nos indicaría que al puntero aun no se le ha asignado una dirección y por lo tanto no podrá ser usada. Ej:


int *p=NULL;


Expresiones con punteros.


En general, las expresiones que involucran punteros se ajustan a las mismas reglas que cualquier otra expresión de C. Esto quiere decir que si tenemos:


int x;

int *p1, *p2;


podemos hacer:


p1 = &x;

p2 = p1;


ó hacer operaciones simples con punteros ó incluso comparaciones. P. ej.:


if (p1 == p2) printf(“Ambos apuntan al mismo sitio\n”);

p2 = p1 +1; // Ésto es aritmética de punteros.

p2--;

p1++;


Pero no se permiten otras operaciones aritméticas con punteros. Así una variable puntero no puede ser multiplicada por una constante, dos punteros no pueden sumarse, etc.


Aritmética de punteros.


Existen sólo dos operaciones aritméticas que pueden hacerse con punteros: la suma y la resta. En particular, un valor entero puede ser sumado o restado a una variable puntero, pero el resultado de la operación debe ser interpretado con cuidado. Supongamos que px es una variable puntero que representa la dirección de una variable x. Se pueden escribir expresiones como ++px, --px, (px+3). Cada expresión representa una dirección localizada a cierta distancia de la posición original representada por px. La distancia exacta será el producto de la cantidad entera por el número de bytes que ocupa cada elemento al que apunta px. Es por eso que NO ES LO MISMO UN PUNTERO A CHAR QUE UN PUNTERO A INT. Ambos punteros contendrán una dirección de memoria pero, por ejemplo, en el siguiente caso:


char ch='a';

char *pch=&ch; // Asignamos al puntero pch la dirección de ch.

// Supongamos que la dirección es 1000

pch++; // Incrementa pch a 1001 ya que los tipo char ocupan 1 byte.


Sí, es cierto, no se ve complicaciones ya que pch contiene el valor que esperábamos. En cambio:


int x=10;

int *px=&x; // Asignamos al puntero px la dirección de x.

// Supongamos que la dirección es 1000

px++; // Incrementa px a 1002 ( no a 1001 ) ya que los tipo int ocupan 2 bytes.


Ahora sí, px tendrá 1002, NO 1001 como quizá se esperaba. Esto es debido a que “toda la aritmética de punteros es relativa al tipo base”. Esto es de gran utilidad para manejar arreglos ( entre otras utilidades ) pero este punto lo estudiaremos más adelante.


Las operaciones que se pueden realizar sobre punteros:


  1. A una variable puntero se le puede asignar la dirección de una variable ordinaria (pv=&v).

  2. A una variable puntero se le puede asignar el valor de otra variable puntero siempre que ambas apunten al mismo tipo de dato.

  3. A una variable puntero se le puede asignar el valor nulo (NULL).

  4. Una cantidad entera puede ser sumada o restada a una variable puntero.

  5. Una variable puntero puede ser restada de otra con tal de que ambas apunten a elementos del mismo arreglo. ( OJO: este caso es muy especial ).

  6. Dos variables puntero pueden ser comparadas siempre que ambas apunten a datos del mismo tipo.

  7. Por último, como ya se comentó, no se permiten otras operaciones aritméticas con punteros. Así una variable puntero no puede ser multiplicada por una constante, dos punteros no pueden sumarse, etc.


Funciones de asignación dinámica.


Una vez compilados, todos los programas en C organizan la memoria de la computadora en cuatro regiones que contienen: el código del programa ( en lenguaje máquina ), los datos globales, la pila ( o Stack ) a través del cual se pasan argumentos y donde residen las variables locales y el montón ( o Heap ) que es el área de memoria libre que es gestionada por las funciones de asignación dinámica de C. Existen dos funciones básicas para trabajar con asignaciones dinámicas. Estas son:


void *malloc(num_bytes);

void free(void *p);


La función malloc() asigna “num_bytes” número de bytes de memoria y devuelve un puntero void ( sin tipo predeterminado. Si se desea que sea de algún tipo, debe usarse la expresión “(tipo *)” antes del malloc(). Ej: (int *)malloc(128); ) del comienzo de dicha memoria y free() devuelve al Heap la memoria previamente asignada para que pueda ser reutilizada. Ambas funciones están el el archivo de cabecera “stdlib.h”.


Como comentamos en el punto “Declaración de arreglos dinámicos”, ésto es muy útil para todos esos casos en los que _NO_ sabemos con antelación de cual tamaño será la variable que vamos a usar. Supongamos el caso en que tenemos un programa que leerá caracteres de un archivo. Nosotros no sabemos con antelación de que tamaño será ese archivo. Así que podríamos crear un arreglo que contenga espacio suficiente como para almacenar el mayor arreglo posible... quizá 100.000 caracteres pero ésto es increíblemente ineficiente ya que la memoria se estaría usando aunque no fuese necesaria ( quizá para leer un archivo de tan sólo 4 bytes, por lo que nos quitaría memoria para otras operaciones realmente necesarias ). Mejor es que usáramos una asignación dinámica con el malloc(). Así, supongamos que leemos un archivo y este contiene 500 bytes, entonces nos bastaría hacer algo como:


char *p;

unsigned long int i;

// leemos el archivo. Asignamos en i el número de bytes que contiene ( 500 bytes ).

p=(char *)malloc(i);


Después de la asignación, p apunta al primero de los 500 bytes de la memoria libre que ha sido reservada por el malloc(). Luego de usarlo, y si ya no es más necesaria su existencia, podemos devolver esa memoria al Heap para ser reutilizada. Esto se hace:


free(p);


Eso sí, como el Heap no es infinito, siempre que se asigne memoria es necesario antes de usarla comprobar el valor devuelto por malloc() y así asegurarse que no es nulo. Un puntero nulo indicará un error en la asignación... p. ej. que no hay memoria suficiente. Si se utiliza un puntero nulo el programa fallará. Por lo tanto, siempre es conveniente hacer algo como:


if((p=malloc(100)==NULL) {

printf(“No hay memoria\n”);

exit(1);

}


También existe una función muy importante en C llamada sizeof(tipo). Esta función retorna la cantidad de bytes que ocupa una variable de tipo “tipo” lo que asegura un buen funcionamiento del programa sin importar el compilador lo que a su vez le da gran portabilidad al código amén del hecho de que así no tenemos que memorizar los tamaños de todos los tipos ni calcular con anterioridad el tamaño de un tipo de dato creado por nosotros ( que se verá algunas clases más adelante ) . Supongamos que necesitamos espacio para guardar 10 variables de tipo double, podemos hacer:

p = malloc(10*sizeof(double));


y listo.


Estructuras de datos.


Pila ( stack ).


La Pila se caracteriza por ser una estructura de datos en la que el último elemento que se añade a la estructura es el primero en salir. Este modo de funcionamiento se conoce como política FILO ( First In, Last Out) ó LIFO (Last In, First Out). En todo caso, el elemento que ocupa el extremo de la pila ( ó su posición ) se denomina tope.


Podemos hacernos una imagen más gráfica, pensando en una pila de bandejas en una pizzería ó pila de platos en un fregadero: en cualquiera de estos ejemplos, los elementos se retiran y se añaden por un mismo extremo. En una pila de platos podríamos intentar retirar uno de los intermedios con el consiguiente peligro de derrumbe. Sin embargo, en una estructura de datos de tipo pila, esto no es posible. El único elemento de la pila que podemos retirar es el situado en el “tope” de la misma, y si queremos retirar otro, será necesario previamente haber borrado todos los situados "por encima" de él.


Sobre una pila podemos realizar dos operaciones básicas de manipulación:




Cola ( queue ).


El concepto de cola es ampliamente utilizado en la vida real. Cuando nos situamos ante la taquilla del cine para obtener nuestra entrada ó cuando esperamos nuestro turno en el comedor de la universidad ( sin contar los “coleones” ), solemos hacerlo en una cola. Ésto significa que formamos una fila en la que el primero que llega es el primero en obtener el servicio y salir de la misma ( en un mundo ideal y perfectamente educado sin abusadores que se creen “más vivos” que los demás ). Esta política de funcionamiento se denomina FIFO (First In First Out), es decir, el primer elemento en entrar es el primer elemento en salir.


Claro que en la vida real puede perfectamente ocurrir que alguien pretenda saltarse su turno en una cola, o incluso que abandone la misma antes de que le toque el turno. Sin embargo, en ambos casos se está incumpliendo la política de funcionamiento de la cola y, estrictamente hablando, ésta deja de serlo.



PUNTEROS Y FUNCIONES.


La relación entre los punteros y las funciones , puede verse en tres casos distintos , podemos pasarle a una función un puntero como argumento (por supuesto si su parámetro es un puntero del mismo tipo ) , pueden devolver un puntero de cualquier tipo ( como ya hemos visto con malloc() ) y es posible también apuntar a la dirección de la función , en otras palabras , al código en vez de a un dato.



Punteros como parámetros de funciones.


A menudo los punteros son pasados a las funciones como argumentos. Esto permite que datos de la porción de programa desde el que se llama a la función sean accedidos por la función, alterados dentro de ella y devueltos de forma alterada. Este uso de los punteros se conoce como paso de argumentos por dirección o por referencia ( como vimos en funciones ).

Cuando los punteros son usados como argumento de una función, es necesario tener cuidado con la declaración de los argumentos formales dentro de la función. Los argumentos formales que sean punteros deben ir precedidos por un asterisco. Esto ya lo vimos en funciones pero veamos otro ejemplo:


#include<stdio.h>


void f1(int u, int v);

void f2(int *pu, int *pv);


void main() {

int u=1,v=3;

f1(u,v);

printf("después de la llamada a f1: u=%i v=%i\n",u,v);

f2(&u,&v);

printf("después de la llamada a f2: u=%i v=%i\n",u,v);

}


void f1(int u, int v) {

u=0;

v=0;

return;

}


void f2(int *pu, int *pv) {

*pu=0;

*pv=0;

return;

}


La salida del programa:


después de la llamada a f1: u=1 v=3

después de la llamada a f2: u=0 v=0


Ok, ahora, como vimos en arreglos, el nombre de un arreglo es un puntero al mismo y representa la dirección del primer elemento del arreglo. Por tanto, un nombre de arreglo se trata como un puntero cuando se pasa a una función y no hay que preceder el arreglo con el '&' dentro de la llamada a función ( por eso dijimos en la clases de arreglos que la única forma de pasar un arreglo a una función es por referencia ).


OJO: Hay que recordar que la función scanf() requiere que sus argumentos vayan precedidos por &, puesto que necesita la dirección de los elementos que van a ser leídos. Los & proporcionan un medio para acceder a las direcciones de variables ordinarias. Los & no son necesarios con nombres de arreglos ya que estos mismos representan direcciones.


Punteros como resultado de una función.


Una función también puede devolver un puntero a la parte llamante del programa. Para hacer esto, la definición de la función y cualquier declaración de la función debe indicar que la función devolverá un puntero. Esto se realiza precediendo la función con un '*'. He aquí la parte de un código de programa que ilustra ésto:


double *f1(double []); // Retorna un puntero a double.


void main() {

double z[10];

double *pz;

pz = f1(z); // Le asigno al puntero pz el puntero retornado por la función f1.

}


double *f1(double z[]) {

double *pf;

pf=....;

return(pf);

}


Las funciones que retornan punteros son por lo general aquellas que modifican un argumento , que les ha sido pasado por dirección ( por medio de un puntero ) , devolviendo un puntero a dicho argumento modificado , ó las que reservan lugar en el Heap para las variables dinámicas , retornando un puntero a dicho bloque de memoria ( tal como vimos con malloc() ).

Así podremos declarar funciones del tipo de:


char *funcion1( char * var1 );

double *funcion2(int i , double j , char *k );


El retorno de las mismas puede inicializar punteros del mismo tipo al devuelto , ó distinto. Algunas funciones , tal como malloc(), definen su retorno como punteros a void ( o sea, sin tipo predeterminado ):


void *malloc( int tamano );


de esta forma al invocarlas debemos indicar el tipo de puntero de deseamos:


p = (double *)malloc( 64 );


en el caso de que requiramos operaciones de “aritmética de punteros” con ella.


Punteros a funciones.


Esta es una característica algo confusa pero muy útil de C. Incluso aunque una función no es una variable, tiene una posición física en memoria que puede asignarse a un puntero. La dirección es el punto de entrada de la función. Por ello, se puede usar un puntero a una función como argumento en la llamada de otras funciones.


Para entender como funcionan los punteros a funciones, se tiene que entender un poco cómo se compila y se llama a una función en C. Cuando se compila cada función, el código fuente se transforma en código objeto y se establece un punto de entrada. Cuando se llama a la función mientras se ejecuta el programa lo que se hace es una llamada en lenguaje máquina a ese punto de entrada. Por lo tanto, un puntero a una función contiene realmente la dirección de memoria del punto de entrada a la función. Es como hacer en Assembler un JUMP a la dirección de la función.


La dirección de una función se obtiene de forma análoga a las direcciones de los arreglos. Esto es que la dirección de una función es el nombre de la función SIN LOS PARENTESIS ni argumentos. Veamos un ejemplo:


#include <stdio.h>


void printMensaje (float dato);

void printNumero (float dato);

void (*funcPuntero)(float);


void main() {

float pi = 3.14159;

printMensaje(pi);

funcPuntero =printMensaje;

funcPuntero (pi);

funcPuntero = printNumero;

funcPuntero (pi);

printNumero(pi);

}


void printMensaje(float dato) {

printf(" Esta es la función printMensaje\n");

}


void printNumero(float dato) {

printf(“Este es el dato: %f\n", dato);

}


La salida del programa será:

Esta es la función printMensaje

Esta es la función printMensaje

Este es el dato: 3.14159

Este es el dato: 3.14159

Ok, hemos declarado dos funciones ( printMensaje y printNumero ) y después “funcPuntero” que es un puntero a una función que recibe un parámetro real y no devuelve nada (void). Las dos funciones declaradas anteriormente se ajustan precisamente a este perfil, y por tanto pueden ser llamadas por este puntero.


En la función principal, llamamos a la función
printMensaje con el parámetro pi, y en la línea siguiente asignamos al puntero a función funcPuntero el valor de printMensaje y utilizamos el puntero para llamar a la misma función de nuevo. Por tanto, las dos llamadas a la función printMensaje son idénticas gracias a la utilización del puntero funcPuntero.



Dado que hemos asignado el nombre de una función a un puntero a función, y el compilador no da error, el nombre de una función debe ser un puntero a una función. Esto es exactamente lo que sucede. Un nombre de una función es un puntero a esa función, pero es un puntero constante que no puede ser cambiado. Sucede lo mismo con los vectores: el nombre de un vector es un puntero constante al primer elemento del vector.


Ya que el nombre de una función es un puntero a esa función, podemos asignar el nombre de una función a un puntero constante, y usar el puntero para llamar a la función. Pero el valor devuelto, así como el número y tipo de parámetros deben ser idénticos en el caso de que sean declarados. Muchos compiladores de C y C++ no avisan de las diferencias entre las listas de parámetros cuando se hacen las asignaciones. Esto se debe a que las asignaciones se hacen en tiempo de ejecución, cuando la información de este tipo no está disponible para el sistema.


Si en el ejemplo anterior añadimos una función que tiene como parámetro un entero e intentamos llamarla con el puntero a función
funcPuntero, el compilador lanzará el siguiente mensaje de error:



error: In this statement, the referenced type of the pointer value "print_int" is "function (int) returning void", which is not compatible with "function (float) returning void". Compilation terminated with errors.


OJO: Entienda la diferencia entre:


int *func(int arg);


y


int (*func)(int arg);


La primera es el prototipo de una función llamada “func” que recibe un entero como argumento y retorna un puntero a entero como resultado. En cambio la segunda declara “func” como un puntero a una función que recibe un argumento entero y retorna un entero, o sea, NO ES UN PROTOTIPO.


OTRO TIP: Los tipos de argumentos a ser recibidos por un puntero a función no tienen que estar necesariamente especificados. Lo importante es que si lo están, todas las llamadas deben coincidir con ese tipo. Veamos un ejemplo en el que el tipo no está especificado:


#include<stdio.h>

#include<conio.h>


int suma(int a, int b);

int resta(int a, int b);

int cuadrado(int a);


void main() {

// Declaramos un puntero a funciones con dos parámetros

// enteros que devuelven un entero

// int (*funcion)(int,int);

// Pero también podemos definirlo sin tipos de argumentos y

// ambos darán el mismo resultado.

int (*funcion)();

int x;

funcion = suma; // ‘funcion’ apunta a ‘suma’

x = funcion(4,3); // x=suma(4,3)

printf("Suma de 4 y 3: %i\n",x);

funcion = resta; // ‘funcion’ apunta a ‘resta’

x = funcion(4,3); // x=resta(4,3)

printf("Resta de 4 y 3: %i\n",x);

funcion = cuadrado; // ‘funcion’ apunta a 'cuadrado'

x = funcion(4); // x=cuadrado(4). Sí, sólo es un argumento.

printf("Cuadrado de 4: %i\n",x);

}


int suma (int a, int b) {

return a+b;

}


int resta (int a, int b) {

return a-b;

}



int cuadrado (int a) {

return a*a;

}