Punteros#
Un puntero es el tipo de variable, por excelencia, que más atemoriza a los desarrolladores junior. Sin embargo, hay algunos conceptos que son clave para comprender este tema:
- Un puntero es una variable que almacena una dirección de memoria.
- Todo puntero sano hace referencia a una variable (a través de su dirección de memoria), salvo que sea
NULL
.
Entonces ¿qué es un puntero? Es un tipo de variable que se utiliza para almacenar la dirección de memoria de otras variables.
Definición#
Para definir una variable de tipo puntero, se utiliza la siguiente expresión:
donde T
es el tipo de la variable apuntada.
De este modo, se cumple lo siguiente:
Definición | Descripción | Tamaño |
---|---|---|
int *x |
x es una variable de tipo puntero a int (int * ) |
8 bytes |
double *x |
x es una variable de tipo puntero a double (double * ) |
8 bytes |
float *x |
x es una variable de tipo puntero a float (float * ) |
8 bytes |
char *x |
x es una variable de tipo puntero a char (char * ) |
8 bytes |
short (*x)[] |
x es una variable de tipo puntero a arreglo de shorts (short (*)[] ) |
8 bytes |
char (*x)[] |
x es una variable de tipo puntero a arreglo de chars (char (*)[] ) |
8 bytes |
char **x |
x es una variable de tipo puntero a puntero a char (char ** ) |
8 bytes |
char **x |
x es una variable de tipo doble puntero a char (char ** ) |
8 bytes |
int ***x |
x es una variable de tipo triple puntero a int (int *** ) |
8 bytes |
int *(*pf)(int *) |
pf es una variable de tipo puntero a función que recibe un puntero a int y retorna un puntero a int (int *(*)(int *) ) |
8 bytes |
Como se ve, se pueden construir variables de tipo puntero con una infinidad de combinaciones. La página web cdecl sirve para hacer traducciones de definiciones al inglés. Lo que se debe destacar, es que siempre almacenan una dirección de memoria (o posiblemente basura si no están inicializados).
Inicialización y asignación#
A una variable de tipo puntero se le deben asignar direcciones de memoria, y para ello es necesario utilizar el operador
&
, u «operador de dirección».
Este operador, aplicado sobre una variable o función, nos da su dirección de memoria.
Por ejemplo, dado el siguiente código
Y el resultado de ejecutar dicho programa puede ser:
$ gcc -std=c17 -Wall -pedantic -o dirop dirop.c
$ ./dirop
El valor **almacenado** en ip es: 0xd4
La dirección de memoria de ivar es: 0xd4
La dirección de memoria de ip es: 0xe0
La dirección de memoria de c es: 0xd3
La dirección de memoria de sz es: 0xd8
Podemos observar que la variable ivar
comienza en la posición de memoria 0xd4
(y termina en 0xd8
no inclusive
porque ocupa 4 bytes), mientras que ip
comienza en la dirección de memoria 0xe0
y almacena el valor 0xd4
.
Y esto puede verse en memoria como se muestra en la siguiente figura:
Pero también podría suceder que la dirección de memoria de ivar
sea 0x7542ffbc
, mientras que ip
podría encontrarse
en la dirección de memoria 0x42d9b030
y almacenaría el valor 0x7542ffbc
.
Esto se muestra en la siguiente figura:
Como podemos ver, las direcciones de memoria de ip
y de ivar
pueden estar distanciadas por más que un par de bytes
(en el ejemplo, son aproximadamente 100 MB de diferencia).
Es decir, un puntero nos sirve para apuntar a direcciones de memoria, cercanas o lejanas.
Esto es realmente importante cuando pensamos en el scope o alcance de las funciones.
Modificación#
Un puntero al que le asignamos una dirección de memoria y no la usamos, no es de mucha utilidad.
Con el puntero, lo que haremos es modificar la variable apuntada.
Para ello usamos el operador *
, u «operador de indirección».
Siguiendo el caso donde ip
es un puntero a entero, y particularmente apunta a ivar
, la sentencia que se utiliza es:
El código completo para modificación de la variable podría ser:
Que al ser compilado y ejecutado muestra lo siguiente:
$ gcc -std=c17 -Wall -pedantic -o dirmod dirmod.c
$ ./dirmod
El valor **almacenado** en ip es: 0x7ffd9a148b64
La dirección de memoria de ivar es: 0x7ffd9a148b64
El valor almacenado en ivar es: 10
El valor **almacenado** en ip es: 0x7ffd9a148b64
La dirección de memoria de ivar es: 0x7ffd9a148b64
El valor almacenado en ivar es: 303
Es importante entender que, al acceder al dato, el compilador entiende que ip
es un puntero a entero y debe acceder
como un entero.
Por ejemplo, el valor 303
no entra en un byte (un char
), son necesarios al menos 2.
Como el tipo apuntado por ip
es un int
(mejor dicho, como ip
es un puntero a entero), el compilador entiende
que el dato apuntado ocupa 4 bytes.
Ejemplo: apuntado incorrectamente
Veamos un código similar al anterior, donde forzamos que ip
, en lugar de apuntar a ivar
, al int
, apunta a
c
, el char
.
Lo primero que debemos notar es que al compilar, en este caso, nos muestra un warning:
$ gcc -std=c17 -Wall -pedantic -o dirmod_w dirmod_w.c
dirmod_w.c: In function ‘main’:
dirmod_w.c:11:8: warning: assignment to ‘int *’ from incompatible pointer type ‘char *’ [-Wincompatible-pointer-types]
11 | ip = &c;
| ^
Dicho warning es claro en que estamos asignando direcciones incorrectas al puntero. Sin embargo, no podemos depender del compilador para verificar esto y hay que estar atento a los tipos.
Y en cuanto a la ejecución podemos ver la siguiente salida:
$ ./dirmod_w
El valor **almacenado** en ip es: 0x7ffe57c76003
La dirección de memoria de ivar es: 0Antesx7ffe57c76004
El valor almacenado en ivar es: 10
La dirección de memoria de c es: 0x7ffe57c76003
El valor almacenado en c es: a
El valor **almacenado** en ip es: 0x7ffe57c76003
La dirección de memoria de ivar es: 0x7ffe57c76004
El valor almacenado en ivar es: 1
La dirección de memoria de c es: 0x7ffe57c76003
El valor almacenado en c es: /
¡Se modificaron 2 variables! Y se debe a que el valor asignado no cabe en el char
apuntado y sobreescribe
parte del int
que se encontraba a continuación.
La siguiente figura ejemplifica, en memoria, lo sucedido:
¿Qué ocurriría en el caso contrario, donde apuntamos a una dirección con un dato más grande que el esperada, por
ejemplo, si apuntásemos a la variable sz
con ip = &sz;
?
Punteros y arreglos#
En C, cuando se define un arreglo, estático, del siguiente modo:
estamos creando una variable, un arreglo, de 4 bool
s.
Esto implica que en memoria tenemos 4 bool
s contiguos, y flags
almacena la dirección de memoria del primer
elemento.
De ese modo, cuando accedemos utilizando el operador []
, como en flags[1] = true;
el compilador entiende que
flags
es un arreglo de booleanos (que ocupan un byte cada uno) y sabe que si el primer elemento está en la posición de
memoria 0xf0
, el elemento flags[1]
está en la posición de memoria 0xf1
.
Chequeando las direcciones
Escribir un programa que cree un arreglo de booleanos e imprima las direcciones de memoria de cada elemento,
utilizando &arreglo[i]
.
Si el arreglo flags
en vez de estar definido con bool
s estuviera definido con int
s, ocurre lo mismo: el
compilador sabe cuántos bytes ocupa cada elemento del arreglo y sabe que al acceder a arreglo[3]
debe ir a la posición
de memoria 0xf0 + 3 * sizeof(int)
, es decir, 0xfc
.
Esta forma de operar es muy similar a lo visto con los punteros, donde el compilador sabe que si escribimos la expresión
*ip
estamos accediendo a una variable de tipo int
que ocupa 4 bytes.
Pero ip
, al igual que flags
, es una variable que almacena una dirección de memoria.
A partir de la dirección de memoria dada, podría haber 1 int
o 100 int
s ¿cómo podemos saberlo?
Lo sabemos porque nosotros lo programamos, u otra variable nos los indicó (o en el caso de las cadenas, porque hay un
'\0'
esperándonos).
Iterando arreglos
Veamos los códigos siguientes:
iteracion1.c | |
---|---|
Es la iteración típica que vimos en la sección de arreglos.
iteracion2.c | |
---|---|
Aquí vemos que podemos almacenar la dirección de memoria del primer elemento de v
en ip
e iterar como si
ip
fuese un arreglo.
En este caso, se dice que la variable v
decae a puntero al almacenar su contenido en el puntero.
iteracion3.c | |
---|---|
$ gcc -std=c17 -Wall -pedantic -o it3 iteracion3.c
$ ./it3
0x7ffd57527bc0
0x7ffd57527bc4
0x7ffd57527bc8
0x7ffd57527bcc
0x7ffd57527bd0
Aquí podemos ver las direcciones de memoria de cada elemento del arreglo.
Vemos que la primera comienza en 0x7ffd57527bc0
y a partir de ese momento, avanzan de 4 bytes.
iteracion4.c | |
---|---|
¿Por qué sale 0 1 2 3 4
si ip
almacena una dirección de memoria, y en “Iteración 3” vimos que aumentan de 4
(sizeof(int)
) estas direcciones?
Porque el compilador es “inteligente” y entiende que no estamos interesados en la diferencia de las direcciones,
sino en cuántos int
s hay entre los punteros.
Para ello, claro está, esa diferencia debería ser válida, es decir, debería darse con direcciones de emoria en
un arreglo o, al menos, con enteros de por medio.
Arreglos de punteros#
Así como tenemos los arreglos de tipos básicos, como arreglos de enteros o arreglos de doubles, podemos tener arreglos de punteros. Un arreglo de punteros es una variable, un arreglo, cuyos elementos son punteros a otros tipos de datos. Por ejemplo, podemos definir
que define un arreglo de punteros a double
.
Veremos que estos tipos tienen una utilidad variada, pero no aproximaremos a ellos con tipos conocidos.
También es importante distinguir la definición anterior de la siguiente:
donde, si seguimos las convención de lectura dada para la definición de variables (primero resolvemos paréntesis y luego
de izquierda a derecha), vemos que var
declara un puntero a un arreglo de doubles.
Para mostrar su uso, lo primero que haremos es definir un conjunto de variables de tipo int
, y un arreglo de
punteros a ellas.
Para ello, lo que haríamos sería escribir el siguiente código:
Inicialización de vector de punteros | |
---|---|
Podemos ver que al definir el arreglo p2ints
lo estamos inicializando con las direcciones de memoria de las variables
antes definidas.
Podríamos, en vez de asignarle dichos punteros, inicializarlo con punteros nulos, es decir, con la constante NULL
.
El siguiente extracto de código muestra dicha inicialización y la siguiente asignación de los punteros, para obtener el
mismo resultado que en el código anterior:
Inicialización de punteros a NULL | |
---|---|
Estas variables se distribuyen en memoria como se muestra en la figura 5, a continuación.
Sin embargo, como mencionamos anteriormente, podemos pensar a la memoria como un arreglo continuo de bytes, pero comienza a ser engorroso realizar estos diagramas, por lo que es mejor hacerlo como se muestra en la siguiente figura:
Estos diagramas buscan ser representaciones fieles de la distribución de las variables en memoria, sus tamaños y sus valores. No obstante, suele ser más práctico trabajar con diagramas donde identificamos las variables, identificamos si son arreglos o no, y esquematizamos la relación, como se muestra en la figura 7.
Podemos ver que todas las variables, independientemente del tipo, fueron dibujadas con un cuadrado, los arreglos con varios cuadrados, y los punteros con flechas hacia las variables a las que apuntan.
Ejemplo: Creando, cargando y usando un vector de punteros
En el siguiente código estamos creando un arreglo de punteros a double
, cargándolo con las direcciones de
memoria de otras variables y, a través del arreglo de punteros, guardamos en dichas variables valores convertidos a
según el ingreso del usuario.
Luego, nuevamente a través del vector, imprimimos los valores y calculamos el máximo del arreglo con una función
llamadas maximo
.
Si volvemos sobre la figura 7, podemos ver que el contenido del arreglo de punteros son direcciones de memoria que apuntan a un entero (por el ejemplo dado). Y podemos recordar que un arreglo, el nombre de un arreglo, almacena la dirección de memoria del primer elemento del arreglo. Si unimos estos dos conceptos, vemos que no podemos saber (sin más información), si cada elemento del arreglo de punteros apunta a una variable entera o a una secuencia de variables, es decir, a un arreglo. Es por esto que podemos construir, utilizando la misma sintaxis anterior, arreglos de arreglos.
Definamos primero las filas de una matriz en variables separadas, del siguiente modo:
float fila0[] = {11, 12, 13, 14, 15};
float fila1[] = {21, 22, 23, 24, 25};
float fila2[] = {31, 32, 33, 34, 35};
float fila3[] = {41, 42, 43, 44, 45};
Luego, podemos definir la variable matriz, como un arreglo de punteros, donde cada elemento del arreglo es un puntero a la fila correspondiente:
A diferencia del caso en que sólo apuntamos a una variable, aquí no utilizamos el operador &
en la asignación de los
valores del arreglo de punteros matriz
.
Esto se debe a que las variables fila0
, fila
y fila2
ya son punteros (¿por qué?), y si tomásemos su dirección de
memoria obtendríamos punteros dobles.
Esquemáticamente, esto lo podemos ver como se muestra en la figura 8.
La forma de utilizar esta matriz es similar a las matrices creadas con memoria contigua (float matriz[4][5];
).
Entre las diferencias podemos ver que esta matriz requiere más memoria, para almacenar el arreglo de punteros (lo es que
una desventaja), pero nos permite no tener toda la memoria contigua (ventaja).
Sin embargo, sí presenta como desventaja que para acceder a un elemento de la matriz, necesita acceder al vector de
punteros para obtener la dirección de la fila, y una vez que tiene la dirección de la fila, acceder nuevamente para
obtener el elemento.
En las matrices estáticas, el mismo acceso se resuelve sumando una determinada cantidad de bytes a la posición inicial.
Esta diferencia viene, justamente, de que las filas pueden estar en cualquier dirección de memoria, y no es posible
saber en dónde hasta no acceder al vector de punteros para ver su contenido.
Una última ventaja que tienen los arreglos, o matrices, formados con punteros (los arreglos de punteros), es que cada
elemento es un puntero a una dirección de memoria donde puede haber una cantidad distinta de elementos para cada
dirección.
Por ejemplo, la primera fila podría contener 10 elementos, mientras que la segunda 5.
Claro está que esto tendrá sentido únicamente en la aplicación donde se lo utilice; una matriz siempre tendrá igual
cantidad de columnas en cada fila, pero un arreglo de cadenas (como veremos más adelante) no tiene por qué tener igual
cantidad de elementos en cada fila, en cada cadena.
Ejemplo: punteros dobles como matrices
En el siguiente ejemplo vemos cómo se crea, carga y utiliza una matriz, formada a partir de un arreglo de punteros. Veremos que es muy similar al ejemplo anterior.
Arreglos de cadenas#
Un caso particular de los arreglos de punteros es el de arreglos de cadenas.
Como vimos, un literal de cadena, por ejemplo "Patricio"
es un arreglo de char
terminado en '\0'
y
ubicado en una posición de memoria constante.
No lo podemos modificar.
Al definir una variable como:
estamos guardando el literal en el espacio de memoria mencionado, y la dirección donde está almacenada la 'P'
en la variable cadena
.
Es decir, cadena
es un puntero a char.
También podemos definir un arreglo de cadenas como
con lo cual cargamos en nombre[0]
una dirección de memoria y en nombre[1]
otra dirección.
Esto nos permite operar con cadenas que se encuentran en un arreglo, y a cada una de ellas se puede acceder mediante
nombre_del_arreglo[i]
.
Ejemplo: disculpe ¿tiene …
Supongamos que tenemos un arreglo definido como
char *items[] = {"banana", "pera", "manzana", "apio", "tomate", "caqui", "mora", "hinojo", "alcachofa"};
y queremos escribir una función que dado un arreglo de cadenas como el mencionado y el nombre de una fruta/verdura, nos dice si se encuentra en el arreglo.
La función que implementaríamos sería similar a la siguiente:
y su uso sería: pos = find(items, 9, "mora");
.
Notemos que, al igual que en los otros casos (y como casi siempre que trabajamos con arreglos), necesitamos pasarle
a la función la cantidad de elementos que hay en el arreglo.
Sin embargo, hay un valor de puntero especial que indica que un puntero no está asignado y/o no apunta a una
dirección válida: NULL
.
Aprovechando esta definición, podemos agregar como último elemento del arreglo, el valor NULL
como un
centinela de fin del arreglo, similar a cómo funciona '\0'
para las cadenas.
Entonces, podemos redefinir el arreglo anterior del siguiente modo
char *items[] = {"banana", "pera", "manzana", "apio", "tomate", "caqui", "mora", "hinojo", "alcachofa", NULL};
y reimplementar la función aprovechando dicha definición:
Finalmente, haciendo un uso avanzado de los punteros, podriamos reescribir la función del siguiente modo:
Se recomienda leer detenidamente dicha función y asegurar su comprensión.
Punteros y funciones#
Al pasar variables a las funciones podemos hacerlo por copia, en donde copiamos todo el contenido de la variable al stack de la función, o por puntero, en cuyo caso sólo copiamos la dirección de la variable. Al copiar la dirección de la variable, desde la función tenemos acceso a la variable, lo que nos permite modificarla.
Un primer ejemplo en el que utilizamos punteros para las funciones es cuando utilizamos arreglos.
Por ejemplo, si implementamos una función para calcular el valor medio de un arreglo de double
s:
meand.c | |
---|---|
Veamos qué ocurre al ejecutarla:
Test meand.c | |
---|---|
$ gcc -std=c17 -Wall -pedantic -c -o meand.o meand.c
$ gcc -std=c17 -Wall -pedantic -c -o test_meand.o test_meand.c
$ gcc -std=c17 -Wall -pedantic -o test_meand test_meand.o meand.o
$ ./test_meand
Mean: 245.78
Y vemos que la ejecución es correcta. ¿Pero qué ocurre en la siguientes prueba?
Test meand.c con array de largo cero | |
---|---|
Test meand.c con NULL | |
---|---|
Como podemos ver, la función no opera correctamente, pero son situaciones que podríamos detectar.
Para ver si el largo es cero usaríamos:
Y para ver si array
es NULL
, podríamos usar:
pero el prototipo de nuestra función es double meand(double *, size_t);
¿qué devolvemos en estos casos de error?
¿-1? Podría ser el valor medio ¿0? También.
Cualquier valor podría ser el valor medio calculado, necesitamos otra forma de hacerlo.
Punteros al rescate.
Lo que haremos es pasarle la dirección de memoria de una variable donde queremos almacenar el resultado, y como valor de
retorno indicaremos si lo pudimos calcular o no.
Para ello, modificamos el código de meand()
como se muestra a continuación:
meand_ok.c | |
---|---|
Cuyo “test” completo es:
$ gcc -std=c17 -Wall -pedantic -c -o meand_ok.o meand_ok.c
$ gcc -std=c17 -Wall -pedantic -c -o test_meand_ok.o test_meand_ok.c
$ gcc -std=c17 -Wall -pedantic -o test_meand_ok test_meand_ok.o meand_ok.o
$ ./test_meand_ok
Prueba con valores típicos:
Mean: 245.78
Prueba con arreglo de largo 0:
No se pudo calcular la media (posiblemente el largo 0 tenga algo que ver)
Prueba con arreglo NULL:
No se pudo calcular la media (posiblemente el puntero nulo tenga algo que ver)
int
como valor de retorno
En la función meand()
, retornar un int
no es lo más favorable, ya que se dificulta comprender el código en
la archivo de pruebas.
Para eso, podemos definir nuestro propio tipo de datos
status.h | |
---|---|
El tipo definido internamente es un int
, pero es irrelevante ya que lo que nos interesa es que el mensaje sea
claro.
Veamos su uso en los siguientes archivos:
$ gcc -std=c17 -Wall -pedantic -c -o meand.o meand.c
$ gcc -std=c17 -Wall -pedantic -c -o test.o test.c
$ gcc -std=c17 -Wall -pedantic -o test test.o meand.o
$ ./test
Prueba con valores típicos:
Mean: 245.78
Prueba con arreglo de largo 0:
No se pudo calcular la media (posiblemente el largo 0 tenga algo que ver)
Prueba con arreglo NULL:
No se pudo calcular la media (posiblemente el puntero nulo tenga algo que ver)
Biblioteca estándar para manejo de punteros#
No hay ninguna biblioteca estándar para la manipulación de punteros.
Guías de ejercicios#
La guía de ejercicios de punteros se encuentra aquí.