Herramientas de usuario

Herramientas del sitio


punteros

Punteros

Además del identificador de las variables globales o automáticas, también se puede identificar una variable por su dirección o referencia. Corresponde a la dirección del primer byte que ocupa en memoria. Es decir la menor de las direcciones de los bytes que conforman la variable. En C es posible obtener la dirección de cualquier variable usando el operador unario de prefijo '&'. Para que sea útil esta dirección, se requiere almacenarla en alguna variable. Estas variables se denominan punteros. Por ejemplo:

int v= 20;
int *p; /* Declara el puntero p */

p= &v;  /* Obtiene la dirección de v */

El * en la declaración int *p indica que p es un puntero, es decir una variable que almacena la dirección de otra variable. El int en int *p especifica además que la dirección almacenada es la de una variable de tipo int. La siguiente figura muestra conceptualmente como se relacionan las variables v y p:

Para no entrar a usar valores numéricos de la direcciones en memoria graficaremos una dirección simplemente como una flecha hacia la variable de destino. Diremos que el puntero p apunta a la variable v.

Desreferencia

El operador unario de prefijo '*' permite obtener la variable apuntada por p y se escribe *p. Esto se llama desreferenciar p o también se habla del contenido de p: v en este caso. Si aparece al lado derecho de una asignación significa obtenga el valor de la variable apuntada, por lo tanto el valor de v. Y si aparece al lado izquierdo, es decir es el destino de una asignación, significa cambie el valor de la variable apuntada, y por lo tanto cambia v. Estudie el siguiente código:

int v= 20;
int w;
int *p;

p= &v;

w= *p;  /* Desreferencia un puntero en posición de lectura */
*p= 30; /* Desreferencia un puntero en posición de asignación */

El valor final de w es 20 y el de v es 30. Al asignar 30 a *p, en realidad se está asignando 30 a v. También se puede cambiar la variable apuntada por p asignándole una nueva dirección:

p= &w;
*p= 40;

Note que al asignar un nuevo valor a p se está cambiando directamente el puntero p, no la variable apuntada por p. Entonces ahora p apunta a w. Luego al asignar 40 a *p se está asignando 40 a w. Esto queda representado en la siguiente figura:

Declaración de punteros con valor inicial

Al igual que con cualquier variable, al declarar un puntero es posible darle un valor inicial. Por ejemplo:

  int a= 1, b= 2;
  int *p= &a; /* valor inicial de p, no de *p */
  
  *p= 3;      /* cambia el valor de a */

Aquí el puntero p comienza apuntando a la variable a. Esta sintaxis resulta engañosa porque al escribir int *p= &a parece que se está dando un valor inicial a *p. Pero esto no es así: el valor inicial es para p. Esto se debe a que en el contexto de la declaración int *p=… el asterisco no es el operador de desreferencia. Es solo un símbolo que indica que el identificador que viene a continuación será un puntero.

Por otra parte, en la asignación *p= 2, el asterisco sí es el operador de desreferencia y por lo tanto se está cambiando a, no p.

Ejemplo: función que intercambia los valores de 2 variables

Al igual que en los métodos de Java, los parámetros de las funciones de C se pasan por valor. Esto significa que las asignaciones a los parámetros de la función llamada no cambian los valores de los argumentos en el llamador. Por ejemplo consideremos una función que pretende intercambiar los valores de dos variables. El siguiente código no funciona:

void swap(int x, int y) { /* ¡Incorrecto! */
  int temp= x;
  x= y;
  y= temp;
}

int main() {
  int a= 1, b= 2;
  swap(a, b);
  /* a sigue siendo ==1 y b sigue siendo ==2 */
}

No funciona porque al cambiar x e y en la función swap, se están cambiando los valores de las variables x e y (parámetros) de swap (la función llamada), que resultan ser variables distintas de las variables a y b de main (el llamador). Para lograr la funcionalidad requerida es necesario usar punteros. Este código sí funciona:

void swap(int *p, int *q) {
  int temp= *p;
  *p= *q;
  *q= temp;
}

int main() {
  int a= 1, b=2;
  swap(&a, &b);
  /* ahora a==2 y b==1 */
}

Observe que al llamar swap es necesario usar &a para pasar la dirección de a en vez de su valor. Si se pasa solo a el compilador reclama por un error de tipos. No se debe asignar un entero a un puntero. La siguiente figura explica la relación entre las distintas variables mientras se ejecuta swap:

Por lo tanto en la asignación *p= *q realmente se está asignando a=b. El problema en C es que la función swap no sirve para intercambiar variables de tipo double, o short, o char, etc.

Ejercicio

Escriba la función swap_double que intercambia valores de variables de tipo double.

Función que intercambia las direcciones de 2 punteros

Suponga que ahora tiene este código:

int main() {
  int a= 1, b= 2;
  int *pa= &a, *pb= &b;
  swap_ptr(&pa, &pb);
}

Ahora se busca que después de invocar swap_ptr, pa apunte a b y pb apunte a a. ¿Como habría que definir swap_ptr? Ahora se necesita que los parámetros de swap_ptr sean punteros a punteros. Esto se hace empleando 2 asteriscos en la declaración:

void swap_ptr(int **p, int **q) {
  int* temp= *p;
  *p= *q;
  *q= temp;
}

Note que ahora hay que declarar temp como int *temp porque ahora el tipo de la expresión *p es int*. De otro modo el compilador reclama por un error de tipos. La siguiente figura explica la relación entre las variables durante la ejecución de swap_ptr:

Por lo tanto al asignar *p= *q se está cambiando el valor de px en el llamador (main). Si quisieramos cambiar el valor de la variable apuntada por lo apuntado por p debemos usar 2 operadores de desreferencia, es decir **.

Como entender la declaración de punteros

Hay que confesar que la declaración de un puntero simple int *p o uno doble int **q no suena natural. Pero esta notación se entiende más fácilmente de la siguiente forma:

  • cada vez que aparece la subexpresión *p dentro de una expresión más compleja, el tipo de *p es int
  • de la misma forma el tipo de la subexpresión **q es int

¿Cual sería el tipo de la subexpresión *q? Dedúzcalo a partir de reescribir la declaración de q como int* *q, que por lo demás es equivalente.

Ejercicios

  • Agregue la variable temp al diagrama de más arriba. ¿A qué variable apunta temp?
  • Se agrega la siguiente instrucción al final de swap_ptr:
      **p= 4;

    ¿Qué variable se está modificando?

  • Escriba la función swap_ptr_ptr que intercambia 2 variables de tipo int**.

Comparación de punteros

Se puede determinar si 2 punteros apuntan a la misma variable comparando ambos punteros con los operadores binarios == y !=. Vea este ejemplo:

int a= 100;
int b= 100;
int *p;
int *q;
int *r;

p= &a;
q= &a;
r= &b;

if (p==q) { /* verdadero */
  ... más código ...
}

if (p==r) { /* falso */
  ... más código ...
}

if (*p==*r) { /* verdadero */
  ... más código ...
}

La comparación p==q es verdadera porque p y q apuntan a la misma variable, es decir p y q almacenan la misma dirección. En cambio p==r es falso porque p apunta a la variable a y r apunta a la variable b, es decir no son la misma variable, a pesar de que su valores son iguales (100). Esto queda explicado así:

Aun cuando p!=r, sí se cumple que *p == *r porque en el segundo caso se comparan los valores almacenados en las variables. Como ambas almacenan 100, entonces son iguales.

El puntero nulo

Al declarar variables automáticas sin inicialización, su valor es indeterminado. Nunca considere que es 0. Es decir un puntero recién declarado apunta a una dirección no determinada. Si se desreferencia el puntero, se puede obtener una variable ubicada en cualquier parte de la memoria. Incluso una dirección no asignada al proceso, lo cual se traducirá en una error denominado segmentation fault. Por lo tanto es recomendable dar un valor cuanto antes a cada puntero que se declara.

Si no se sabe a qué variable hacer apuntar un puntero recién declarado, se puede inicializar con el puntero nulo. Esto corresponde a la dirección 0 y en C se representa mediante NULL. Luego se puede comparar un puntero con el puntero nulo para decidir si se puede desreferenciar o no. Vea el siguiente código:

int c;
int *p;
p= NULL;
... más código ...
if (a > b) {
  p= &c;
}
... más código ...
if (p != NULL) {
  *p= 50;
}

Este código asigna 50 a la variable c solo si a>b.

No es válido acceder a la dirección NULL en un proceso y por lo tanto si se desreferencia un puntero nulo se produce el error segmentation fault.

Punteros locos

El tipo del valor retornado por la siguiente función es un puntero. Esto significa que getvar entrega una dirección como retorno.

int *getvar() {
  int x= 1;
  return &x; /* Incorrecto: no haga esto! */
}

int main() {
  int *p= getvar();
  printf("%d\n", *p);
}

Una razón por la cual C es poco robusto es que es fácil que un puntero apunte a variables que ya fueron destruidas. En el ejemplo, getvar retorna un puntero a la variable x que fue creada dentro de getvar y que por lo tanto es destruida al retornar. Muchos compiladores no diagnostican el error. Peor aún: ¡este programa sí podria desplegar 1!

El puntero p se denomina un puntero loco, o puntero colgante (en Inglés dangling pointer o dangling reference). Ocurre cuando apunta a una variable que ya fue destruida. Su comportamiento no está definido y podría ser cualquiera de las siguientes alternativas:

  • Aún cuando la variable fue destruida, su memoria no ha sido reasignada y por lo tanto al desreferenciar p se obtiene el valor correcto.
  • La memoria que ocupó alguna vez x sí fue reasignada y se le dió un nuevo valor distinto de 1. Al desreferenciar p se obtiene un valor incorrecto.

Ejercicio ¿Qué despliega el siguiente programa?

#include <stdio.h>

int *getvar(int x) {
  return &x; /* Incorrecto: no haga esto! */
}

int main() {
  int *p= getvar(1);
  int *q= getvar(2);
  printf("%d %d\n", *p, *q);
}

Variables dinámicas: malloc/free

Una variable dinámica se crea explícitamente llamando a la función malloc. Por lo mismo no tiene identificador, es decir es una variable anónima. La única forma de llegar a una variable dinámica es a través de un puntero. El concepto es el mismo de un objeto en Java que se crea con new. Pero a diferencia de Java, en C todas las variables creadas con malloc se deben destruir explícitamente llamando a la función free. La función free libera la memoria que ocupaba la variable dinámica destruida y puede ser reutilizada en un futuro malloc. Si esa variable no se requiere más y no se libera la memoria que ocupa, entonces se transforma en una gotera de memoria (en Inglés memory leak).

La siguiente versión de la función getvar de más arriba sí es correcta:

int *getvar() {
  int *q= malloc(sizeof(int));
  *q= 1;
  return q;
}

int main() {
  int *p= getvar();
  printf("%d\n", *p);
  free(p);
  return 0;
}

Al invocar malloc se debe especificar el tamaño de la memoria requerida. Usualmente se usa el operador de prefijo sizeof para calcular el espacio requerido por un tipo determinado, en este caso int.

Note que al retornar getvar, se destruyen todas sus variables locales (automáticas). Esto incluye el puntero q. Pero la variable creada con malloc no es destruida. Como su dirección es retornada por getvar y almacenada en el puntero p de main, es legal desreferenciar p y obtener así el valor 1. La siguiente figura muestra las variables que intervienen:

Las variables dinámicas se crean en una zona especial de la memoria de un proceso denominada el heap. Solo malloc y free administran la memoria del heap. Lamentablemente heap también es el nombre de una estructura de datos que se usa para ordenar arreglos con el método heapsort, pero no tienen nada que ver ambos conceptos. Es solo coincidencia de nombres.

No hay recolector de basuras

En Java no es necesario liberar la memoria porque posee un recolector de basuras que recicla automáticamente los objetos que ya no son accesibles por el programa. Pero este recolector tiene un sobrecosto significativo en la ejecución de los programa, sobrecosto que no es deseable para un lenguaje que persigue la eficiencia como C. Por otra parte, la recolección de basuras impone una disciplina en el manejo de los punteros. Esto impediría el uso libertino de la memoria en C, como la aritmética de punteros.

Aritmética de punteros

La característica más importante de C es su flexibilidad con el manejo de memoria, la que se ve reflejada en la aritmética de punteros. Gracias a esta característica es posible programar un sistema operativo o las funciones malloc y free en C de manera eficiente. Sin aritmética de punteros habría que recurrir al assembler.

Ningún otro lenguaje de programación ampliamente usado ofrece aritmética de punteros. Pero esta característica es un arma de doble filo en C, porque también conduce a errores de programación que son difíciles de diagnosticar, incluso para expertos.

Para entender como funciona la aritmética de punteros consideremos la siguiente instrucción:

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

¿De qué sirve pedir espacio para 10 enteros consecutivos en la memoria? Porque a partir del puntero p es posible acceder a todos estos enteros. La expresión p+i, en donde i es un entero entre 0 y 9, apunta al i-ésimo de los enteros. Por lo tanto la expressión *(p+i) representa la i-ésima variable creada. La siguiente figura ilustra el concepto:

El siguiente código asigna i*i a la i-ésima variable, para i desde 0 a 9. Luego suma todos los valores:

  int s= 0;
  int i;
  for (i= 0; i<10; i++)
    *(p+i)= i*i;
  for (i= 0; i<10; i++)
    s+= *(p+i);

Azucar sintáctico

El problema es que escribir *(p+i) es pesado sintácticamente, de modo que en C se define el operador [] como:

p[i] ≡ *(p+i)

Y por lo tanto podemos reescribir el código anterior como:

  int *p= malloc(10*sizeof(int));
  int s= 0;
  int i;
  for (i= 0; i<10; i++)
    p[i]= i*i;
  for (i= 0; i<10; i++)
    s+= p[i];

¿Suena conocido? ¿Parecen arreglos? Sí, son los arreglos en C. En C los arreglos no son más que un puntero al primer elemento del arreglo. Nunca olvide la equivalencia que existe entre punteros y arreglos en C.

¡Cuidado! Un típico error es acceder al elemento con índice 10 del arreglo:

  int *p= malloc(10*sizeof(int));
  int s= 0;
  int i;
  for (i= 0; i<=10; i++) /* error: debería ser i<10 */
    p[i]= 0;

Este código compila correctamente y seguramente se ejecuta pero al modificar p[10] se está modificando alguna zona no definida de memoria, lo que puede conducir a resultados incorrectos que son difíciles de diagnosticar.

Otro error típico en el manejo de memoria en C es el siguiente:

  int *p, *r, x; /* x no es puntero */
  p= malloc(...);
  ...
  r=p;
  ...
  free(p);
  ...
  x= *r; /* r es dangling reference */

Aquí r y p apuntan a la misma dirección de memoria. Al liberar ese espacio con free(p) deja de ser legal acceder a ella por medio de *p y también por medio de *r. Aquí el error es trivial de detectar pero en programas más complejos es muy frecuente que accidentalmente se accede a memoria ya liberada muy lejos del código en donde se invocó free y por lo tanto es un error difícil de diagnosticar. Este error podría conducir a un segmentation fault o también conducir a resultados incorrectos. La herramienta comercial purify o la gratuita valgrind revisan que en la ejecución de un programa no ocurran este tipo de errores. La desventaja es que el tiempo de ejecución se multiplica por 100. Además a veces diagnostican errores inexistentes o peor aún, no detectan todos los errores.

Restricciones

Solo está permitido sumar o restar un entero a un puntero. También está permitido restar 2 punteros del mismo tipo. Pero no tiene sentido multiplicar o dividir un puntero por cualquier valor. Tampoco tiene sentido sumar 2 punteros. Por ejemplo si se tienen las siguientes declaraciones:

int v, u;
int *p= &v, *q= &u, *r;

La siguiente tabla indica qué expresiones son correctas y cuales no:

expresión ¿correcta? tipo de la expresión
p + 10 si int*
q - 2 si int*
p + 2.5 no
p + q no
p - q si int
p * 10 no
p / 10 no

En el caso de la resta de punteros, se cumple esta propiedad: (p + i) - p ≡ i

Arreglos de C vs. arreglos en Java

Dado que en Java los arreglos corresponden a un objeto bien definido con índices que parten en 0 y terminan en el tamaño del arreglo menos 1, Java puede validar cada acceso y arrojar una excepción cuando el índice está fuera del rango permitido. En cambio en C no se puede determinar donde termina un arreglo y ni siquiera donde comienza porque los índices negativos son válidos y por lo tanto es imposible realizar una validación de los índices. C nunca podrá reclamar por un índice fuera de rango.

Mientras en Java es posible determinar el tamaño de un arreglo, en C no hay ninguna forma. Es responsabilidad del programador mantener el tamaño en variables independientes. Por ejemplo, al pasar un arreglo como argumento de una función en C, usualmente también se incluye un argumento con su tamaño.

Gracias a la aritmética de punteros es posible programar en C sistemas operativos, malloc/free o el recolector de basuras de Java. En contrapartida esta aritmética conduce a todo tipo de errores que son muy difíciles de diagnosticar. Cuando se programa en C es mucho más rentable ser obsesivo en revisar una vez y otra vez el código que se escribe que programar rápido y descansar en que los errores se puede corregir haciendo debugging. La suma del tiempo de codificación más el tiempo de debugging será menor en el primer caso que en el segundo.

Cast de punteros

El significado de un cast de un dato primitivo es distinto del de un cast de un puntero. Por ejemplo cuando se tiene un dato de tipo double y se le aplica un cast a tipo int, se realiza una conversión en tiempo de ejecución de la representación del dato. En el bajo nivel la secuencia de bits que representa el número cambia. Incluso puede cambiar el número porque (int)3.14 es 3.

Por otra parte un cast de un puntero no implica ninguna conversión en tiempo de ejecución. La dirección en sí es la misma. Solo cambia la manera en que el compilador interpreta la variable apuntada. Por ejemplo:

  double x= 3.14159;
  double *p= &x;
  int *q= (int*)p;
  int i= q[0]; /* o también *q */
  int j= q[1];

Este código es perfectamente legal en C aunque es difícil predecir cuáles serán los valores de i y j. Ciertamente no es algo ni remotamente parecido a 3 o 3.14159, pero mientras que el compilador considera que *p es una variable real (de tamaño 8), la expresión *q corresponde a una variable entera de 4 bytes. Incluso es legal acceder a los siguientes 4 bytes utilizando q como un arreglo: q[1]. La siguiente figura grafica la situación:

Es importante destacar que en la mayoría de los casos asignar un puntero a otro puntero de distinto tipo conduce a un error de programación. Por eso el compilador reclama si no se coloca el cast int* en el código de más arriba, porque en la mayoría de los caso no era la intención del programador que tuviesen tipos distintos. Es una forma de prevenir al programador de que está haciendo algo peligroso. Cuando el programador coloca el cast, está prometiendo al compilador que sabe lo que está haciendo.

Ejercicio

El siguiente programa es absolutamente legal en C. ¿Qué hace?

  int *p= ( (int*)malloc(10*sizeof(int)) ) + 5; /* Ojo con el + 5 al final */
  int i;
  for (i= -5; i<5; i++)
    p[i]= 0;

Por supuesto, acá no se está fomentando este estilo de programación, que es detestable, si no que sirve para ilustrar que el uso de índices negativos es perfectamente legal y su semántica está bien definida.

Big endian vs. Little endian

¿Qué valor retorna la siguiente función?

int isLittleEndian() {
  int x= 1;
  char *p= (char*)&x;
  return p[0];
}

La siguiente figura muestra la relación entre x y p:

El valor retornado por esta función depende de una propiedad del hardware. En los procesadores x86 los punteros apuntan al byte menos significado de un entero. Esto se denomina una arquitectura little endian. Por lo tanto en este tipo de arquitecturas p[0] es 1 y la función de más arriba retorna 1 (o verdadero). En cambio también existen arquitecturas que se denominan big endian en donde los punteros apuntan al byte más significativo de un entero. La arquitectura Power de IBM es big endian así como los mainframes que todavía fabrica esta marca. La arquitectura Sparc de Oracle también es big endian.

Los únicos casos en donde esta distinción arquitectural es importante es cuando se graban archivos binarios o se transmiten datos binarios por la red. Es decir en vez de escribir los enteros en formato ascii en notación decimal, se escriben directamente los 4 bytes del entero. Si un archivo binario es escrito en una arquitectura little endian pero leído en una arquitectura big endian, los números leídos serán incorrectos.

Por lo tanto la función de más arriba sí es útil para determinar en qué formato enviar valores por la red.

Ejercicio

Programe la función int swapEndianness(int x) que recibe un entero little endian y entrega uno big endian (o viceversa).

Resumen

La siguiente tabla resume las operaciones con punteros:

Nombre Ejemplo Descripción
declaración de puntero double *p, *q; Se declara un puntero que almacena direcciones a variables de tipo double.
asignación de un puntero q = & pi; Hace que p apunte a la variable pi, que debe ser de tipo double.
desreferencia en lectura x= *q; Lee el valor almacenado en la variable apuntada por q (pi en este caso)
desreferencia en escritura *q= 3.14 Modifica el valor almacenado por la variable apuntada por q (pi en este caso)
comparación p==q Es verdadero si p y q apuntan exactamente a la misma variable
puntero nulo p==NULL Verdadero si p apunta a la dirección 0 de memoria
aritmética de punteros p+i Entrega la dirección de la i-ésima variable consecutiva en memoria con *p
punteros.txt · Última modificación: 2021/09/22 17:07 por lmateu