13. Punteros en C

Última modificación por Mario Gorga López el 2019/03/07 21:09

Objetivos

  1. Introducir el concepto de puntero.
  2. Entender la relación entre puntero y vectores/matrices en C.
  3. Operadores básicos de acceso.

Introducción

En esta guía se introduce un nuevo tipo de datos llamado puntero. Los punteros son variables que en vez de contener un valor (como los enteros, caracteres o reales), contienen una dirección de memoria. A continuación veremos sus propiedades, operaciones y relación con otros conceptos que hemos visto anteriormente.

1. Uso de punteros

Un puntero es una variable cuyo valor es una dirección de memoria. Dado que un puntero contiene una dirección de memoria, el espacio que ocupa en memoria será siempre el mismo, el tamaño de una dirección. Este tamaño dependerá de si nuestro sistema es de 32 bits o 64 bits, pero será independiente del tipo que le asignamos al puntero.

1.1. Declaración

Al igual que cualquier otro variable, un puntero se declarará para poderlo utilizar:

[Ejemplo 13_01]

var 
    p: pointer to type;  
end var

p:=NULL;

#include <stdio.h>

int main() {

   /* Variable definition */
    type *p=NULL;

}

Donde type es cualquier tipo de datos (entero, real, etc.). La constante NULL tiene un valor 0, y se interpreta como que este puntero no ha sido inicializado, o sea, que no apunta a ningún sitio.

1.2. Operaciones

Aunque las direcciones son iguales sea cual sea el tipo, se le asigna un tipo para indicar qué contiene la dirección que se guarda en este puntero. Hay que tener mucho cuidado con los tipos de los punteros, aunque los punteros de todos los tipos ocupan lo mismo, el tipo asignado a un puntero tiene un gran efecto en el resultado que se obtiene al operar con él.

Dado que un puntero contiene una dirección de memoria, que es un valor numérico, podemos realizar las mismas operaciones que con cualquier valor numérico:

1.2.1. Asignación

Como hemos dicho anteriormente, un puntero contiene una dirección de memoria. Si queremos que un puntero apunte a una determinada variable, o sea, asignar al puntero la dirección de una determinada variable, lo haremos de la siguiente manera (fijaos en que los tipos deben coincidir entre variable y puntero):

[Ejemplo 13_02]

var 
    a: integer;     
    b: real;
    pa: pointer to integer;  
    pb: pointer to real;  
end var

a:=3;
b:=4.5;

pa:=address(a);
pb:=address(b);

#include <stdio.h>

int main() {

   /* Variable definition */
   int a=3;
   float b=4.5;

   int *pa=&a;
   float *pb=&b;

   return 0;
}

En este ejemplo, a la variable pa se le asigna la dirección de la variable a, y a la variable pb la dirección de la variable b. A continuación, se muestra una representación simplificada de cómo quedaría en memoria:

pointerAssigment.png

Observemos que cuando asignamos a un puntero la dirección de una variable, estamos asignando la dirección del primer byte, por tanto, esta operación no se ve afectada por el tamaño en memoria de la variable (dada por su tipo).

1.2.2. Contenido

La dirección de una variable es una información que por sí sola no nos aporta gran cosa, ya que cada vez que ejecutamos nuestro programa este valor cambiará. Lo realmente importante es el hecho de que a partir de esta dirección podemos acceder al contenido que se guarda en esa posición de memoria. Siguiendo con el ejemplo anterior, a partir de la dirección guardada en pb, podemos recuperar el contenido b. Dicho de otro modo, podemos acceder al contenido de la variable apuntada por pb.

El siguiente código muestra el operador contenido, que nos permite recuperar el contenido a partir del puntero.

[Ejemplo 13_03]

var 
    a: integer;     
    pa: pointer to integer;       
end var

a:=5;
pa:=NULL;

pa:=address(a);
pa^:=25;

#include <stdio.h>

int main() {

   /* Variable definition */
   int a=5;
   int *pa=NULL;

    pa=&a;
   *pa=25;

   return 0;
}

Observad que cuando recuperamos el contenido, el tipo del puntero es muy importante, ya que el puntero solo contiene la dirección inicial y el tipo es lo que nos permitirá saber cuántos bytes hay que utilizar y cómo interpretarlos. A continuación, se muestra una representación de la memoria en diferentes situaciones:

  1. El estado inicial de las variables.
  2. El resultado de asignar la dirección de a al puntero pa.
  3. Cambiar el contenido del puntero pa, asignándole un nuevo valor.

valueAssigment.png

Hay que tener presente que el contenido de un puntero a entero es un entero y, por lo tanto, se puede asignar a una variable de tipo entero y se le puede aplicar cualquier operación entera, como por ejemplo, sumarle otro entero o escribirlo por pantalla. Lo mismo ocurre con cualquier otro tipo, o sea, el contenido de un puntero a real es un real y por lo tanto lo podemos tratar como tal.

1.2.3. Operaciones lógicas

Hay que tener muy presente que cuando comparamos dos punteros, como en el siguiente código:

[Ejemplo 13_04]

var 
    a: integer;
    b: integer;
    pa: pointer to integer;  
    pb: pointer to integer;  
end var

a:=3;
b:=5;

pa:=address(a);
pb:=address(b);

if pa < pb then
   writeString("pa is smaller than pb");
end if

#include <stdio.h>

int main() {

   /* Variable definition */
   int a=3;
   int b=5;

   int *pa=&a;
   int *pb=&b;

   if (pa < pb) {
        printf("pa is smaller than pb");
    }

   return 0;
}

Estamos comparando las direcciones que guardan, no los valores de las variables a las que apuntan. O sea, aunque a<b es cierto, pa<pb no tiene un valor conocido, ya que dependerá de las direcciones de a y b. Si lo que queremos comparar es el contenido de la dirección a la que apunta el puntero, habrá que utilizar el operador de contenido, como se hace en el siguiente código:

[Ejemplo 13_05]

var 
    a: integer;
    b: integer;
    pa: pointer to integer;  
    pb: pointer to integer;  
end var

a:=3;
b:=5;

pa:=address(a);
pb:=address(b);

if pa^ < pb^ then
    writeString("the value pointed by pa is smaller than the value pointed by pb");
end if

#include <stdio.h>

int main() {

   /* Variable definition */
   int a=3;
   int b=5;

   int *pa=&a;
   int *pb=&b;

   if (*pa < *pb) {
         printf("the value pointed by pa is smaller than the value pointed by pb");
    }

   return 0;
}

1.2.4. Operaciones aritméticas

Como hemos visto anteriormente, un puntero es un tipo de datos que permite guardar una dirección de memoria. Las direcciones son valores numéricos y, como tales, les podemos aplicar operadores aritméticos. Existe una diferencia importante en el comportamiento de estos operadores cuando se aplican a punteros, ya que se trabaja con unidades de datos, no con valores, es decir, si por ejemplo sumamos 3 a un puntero a entero que contiene la dirección 33, el resultado no será la dirección 36, sino que será 33 + 3 * tamaño(entero) = 33 + 3 * 4 = 45. Del mismo modo, si tenemos un puntero a carácter que contiene la dirección 33 y le sumamos 3, como el tamaño de un carácter es 1 byte, el resultado obtenido será 33 + 3 * tamaño(carácter) = 33 + 3 * 1 = 36.

Por tanto, el tipo del puntero determina el resultado final de la operación.

1.3. Ejemplo 13_06

Para ver cómo funcionan los punteros, fijémonos en el siguiente ejemplo:

var 
    a: integer;
    b: integer;
    p: pointer to integer;  
fvar

a:=3;
b:=5;
p:=NULL;

p:=address(a);
p^:=a+b;

#include <stdio.h>

int main() {

   /* Variable definition */
   int a=3;
   int b=5;
   int *p=NULL;

    p=&a;
   *p=a+b;

   return 0;
}

Lo que se hace en este código es:

  1. Define tres variables: dos enteros a y b, y un puntero a entero p. Asignamos los valores iniciales 3, 5 y null respectivamente a las tres variables.
  2. Asignamos a p la dirección de a, que es la dirección donde comienza a en la memoria. Diremos que p apunta a a.
  3. Al contenido de p, le asignamos la suma entre a y b. Dado que p apunta a a, el contenido de p equivale a a, y por lo tanto esto es equivalente a asignar el resultado de la suma directamente a a.

A continuación, se ve gráficamente la evolución de la memoria para cada uno de los pasos de este código:

pointerSample1.png

Es necesario que nos fijemos especialmente en la diferencia entre asignar un valor al puntero o asignarlo al contenido del puntero. En el primer caso, se asigna una dirección, en el segundo, se asigna un valor a la variable apuntada por el puntero. Dicho de otro modo, cuando accedemos al puntero p, accedemos (para leer o escribir) en la dirección de la propia variable p (en el ejemplo la dirección 40), mientras que cuando accedemos al contenido de p accedemos (para leer o escribir) a la dirección que contiene p (en el ejemplo la dirección 32, que equivale a la variable a).

2. Punteros a tuplas

Al igual que tenemos punteros a enteros, los podemos tener a cualquier otro tipo, incluidos los tipos estructurados o tuplas. Sea cual sea la tupla, los campos que contenga y su tamaño en memoria, un puntero a la tupla continuará ocupando lo mismo que un puntero a un carácter o un entero, lo que ocupe una dirección de memoria, y su contenido será la dirección de la primera posición de memoria donde se guarda la tupla.

[Ejemplo 13_07]

type
    tpoint = record
        x: real
        y: real
   end record
end type

var 
    a: tPoint;
    pPoint: pointer to tPoint;
    pCoord: pointer to real;
end var

a.x:=3;
a.y:=5;
pPoint:=NULL;
pCoord:=NULL;

pPoint:=address(a);

pPoint^.x=-2;

pCoord:=address(a.y);

pCoord^:=6;

#include <stdio.h>

int main() {

   /* Type definition */
   typedef struct {
       float x;
       float y;
    } tPoint;

   /* Variable definition */
    tPoint a;
    tPoint *pPoint=NULL;
   float *pCoord=NULL;

    a.x=3;
    a.y=5;

    pPoint = &a;

    pPoint->x = -2;

    pCoord = &(a.y);

   *pCoord=6;

   return 0;
}

Lo que se hace en este código es:

  1. Definir tres variables: una variable a de tipo tPoint, una variable pPoint que es un puntero a tPoint y una variable pCoord que es un puntero a real. Asignamos 3 y 5 en los campos x e y de tPoint respectivamente. Inicializamos Los dos punteros los con el valor null.
  2. Asignamos a pPoint la dirección de la variable a. Por lo tanto, pPoint apunta a a.
  3. Asignamos al campo x del contenido de pPoint el valor -2. En lenguaje C tenemos el operador ->; que facilita esta acción. pPoint->x equivale a (*pPoint).x.
  4. Asignamos a pCoord la dirección del campo y de la variable a. Ten en cuenta que el tipo del campo y del puntero coinciden.
  5. Asignamos al contenido del puntero pCoord (o sea, en el campo y de a) el valor 6.

A continuación, se ve gráficamente la evolución de la memoria para cada uno de los pasos de este código:

pointerSample2.png

3. Punteros y vectores/matrices en C

El caso de los vectores (incluidas las cadenas de caracteres) y las matrices es diferente al resto de los tipos. En realidad, cuando declaramos una variable como vector o matriz, realmente la variable que se declara es de tipo puntero al tipo de este vector/matriz, y su valor es el de la primera posición de este vector/matriz. Aunque en lenguaje algorítmico podríamos expresar la asignación sin problemas, cuando lo implementamos en lenguaje C este hecho provoca dos efectos que hay que tener presentes:

Efecto 1: no podemos asignar los valores de un vector/matriz a otro vector/matriz.

El problema es que al asignar un vector/matriz a otro, realmente lo que hacemos es cambiar la dirección donde apunta el puntero. Veamos un código de ejemplo:

[Ejemplo 13_08]

#include <stdio.h>

int main() {

   /* Variable definition */
   int a[3]={1, 2, 3};
   int b[3]={0, 0, 0};
   
    b=a;

   return 0;
}

En este código, el tipo de las variables a y b realmente es int*, o sea, punteros a enteros. Por tanto, cuando asignamos b a a, estamos asignándole la dirección de la primera posición de a. Si ahora mostráramos el contenido de los dos vectores, podríamos tener la sensación de que la asignación se ha hecho correctamente, ya que en efecto vemos los mismos valores, pero si modificamos cualquiera de los valores de b también se modificarán los valores de a, ya que a efectos prácticos hemos hecho que los dos punteros apunten al mismo vector.

Tampoco funcionaría intentar asignar el contenido del vector, ya que si hacemos:

[Ejemplo 13_09]

#include <stdio.h>

int main() {

   /* Variable definition */
   int a[3]={1, 2, 3};
   int b[3]={0, 0, 0};
   
   *b=*a;

   return 0;
}

Como a y b apuntan a la primera posición de los vectores, lo que haríamos con este código sería copiar el contenido de la primera posición del vector a, o sea el entero 1 a la primera posición del vector b, el cual pasaría a tener los valores {1, 0, 0}.

Efecto 2: cuando pasamos un vector/matriz como parámetro a una acción o función, este parámetro siempre será de entrada/salida.

Al pasar un vector/matriz realmente estamos pasando un puntero y, por tanto, como hemos visto en el apartado anterior, los cambios que se hagan en este vector perdurarán al finalizar la acción o función. El lenguaje C nos permite protegernos de este efecto. Si queremos evitar que una acción o función modifique los contenidos de un vector/matriz, o de cualquier puntero que le pasamos como parámetro, podemos declararlos como constantes. En el siguiente ejemplo se muestra cómo hacerlo:

[Ejemplo 13_10]

#include <stdio.h>

void f(const int *p) {
   *p=*p+2; /* ERROR */
}

int main() {

   /* Variable definition */
   int a=3;
   
    f(&a);

    printf("%d\n", a);

   return 0;
}

Al haber añadido la palabra reservada const, el compilador no dejará que modifiquemos el contenido del puntero.

Resumen

En esta guía hemos visto un nuevo tipo de datos, el puntero. También hemos visto cómo pasar de una variable a su dirección y cómo obtener el valor de la dirección contenida en un puntero. Finalmente hemos relacionado los punteros con los parámetros de entrada/salida y los vectores y matrices. También hemos visto que, a menos que indiquemos lo contrario explícitamente, cualquier parámetro de una acción o función que sea un vector/matriz es un parámetro de entrada/salida.

Etiquetas:
Creado por editor1 el 2018/09/17 10:34