Saltar a contenido

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:

T *pvar;

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

dirop.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char c = 'a';
    int ivar = 10;
    size_t sz = 1959;
    int *ip;

    ip = &ivar;

    printf("  El valor **almacenado** en ip es: %p\n", (void *) ip);
    printf("La dirección de memoria de ivar es: %p\n", (void *) &ivar);
    printf("  La dirección de memoria de ip es: %p\n", (void *) &ip);
    printf("   La dirección de memoria de c es: %p\n", (void *) &c);
    printf("  La dirección de memoria de sz es: %p\n", (void *) &sz);

    return EXIT_SUCCESS;
}

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:

Fig. 1: Distribución en memoria de las 4 variables del código anterior.

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:

Una posible distribución en memoria de 4 variables Fig. 2: Otra posible distribución en memoria de 4 variables.

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:

*ip = 303;

El código completo para modificación de la variable podría ser:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char c = 'a';
    int ivar = 10;
    size_t sz = 1959;
    int *ip;

    ip = &ivar;

    printf("  El valor **almacenado** en ip es: %p\n", (void *) ip);
    printf("La dirección de memoria de ivar es: %p\n", (void *) &ivar);
    printf("    El valor almacenado en ivar es: %i\n", ivar);

    *ip = 303;

    printf("  El valor **almacenado** en ip es: %p\n", (void *) ip);
    printf("La dirección de memoria de ivar es: %p\n", (void *) &ivar);
    printf("    El valor almacenado en ivar es: %i\n", ivar);

    return EXIT_SUCCESS;
}

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.

dirmod_w.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    char c = 'a';
    int ivar = 10;
    size_t sz = 1959;
    int *ip;

    ip = &c;

    printf("  El valor **almacenado** en ip es: %p\n", (void *) ip);
    printf("La dirección de memoria de ivar es: %p\n", (void *) &ivar);
    printf("    El valor almacenado en ivar es: %i\n", ivar);
    printf("   La dirección de memoria de c es: %p\n", (void *) &c);
    printf("       El valor almacenado en c es: %c\n", c);

    *ip = 303;

    printf("  El valor **almacenado** en ip es: %p\n", (void *) ip);
    printf("La dirección de memoria de ivar es: %p\n", (void *) &ivar);
    printf("    El valor almacenado en ivar es: %i\n", ivar);
    printf("   La dirección de memoria de c es: %p\n", (void *) &c);
    printf("       El valor almacenado en c es: %c\n", c);

    return EXIT_SUCCESS;
}

Lo primero que debemos notar es que al compilar, en este caso, nos muestra un warning:

Compilación de dirmod_w.c
$ 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:

Resultado de la ejecución del dirmod_w
$ ./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:

Fig. 3: Antes de la asignación.

Fig. 4: Después de la asignación.

¿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:

bool flags[4];

estamos creando una variable, un arreglo, de 4 bools. Esto implica que en memoria tenemos 4 bools 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 bools estuviera definido con ints, 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 ints ¿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
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int v[5];

    for (size_t i = 0; i < 5; ++i) {
        v[i] = i;
    }

    for (size_t i = 0; i < 5; ++i) {
        printf("%i ", v[i]);
    }
    putchar('\n');

    return EXIT_SUCCESS;
}
Ejecución
$ gcc -std=c17 -Wall -pedantic -o it1 iteracion1.c
$ ./it1
0 1 2 3 4

Es la iteración típica que vimos en la sección de arreglos.

iteracion2.c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int v[5];
    int *ip = v;

    for (size_t i = 0; i < 5; ++i) {
        ip[i] = i;
    }

    for (size_t i = 0; i < 5; ++i) {
        printf("%i ", ip[i]);
    }
    putchar('\n');

    return EXIT_SUCCESS;
}
Ejecución
$ gcc -std=c17 -Wall -pedantic -o it2 iteracion2.c
$ ./it2
0 1 2 3 4

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
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int v[5];

    for (int *ip = v; ip < &v[5]; ++ip) {
        printf("%p\n", ip);
    }

    return EXIT_SUCCESS;
}
Ejecución
$ 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
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int v[5];

    for (int *ip = v; ip < &v[5]; ++ip) {
        *ip = ip - v;
    }

    for (int *ip = v; ip < &v[5]; ++ip) {
        printf("%i ", *ip);
    }
    putchar('\n');

    return EXIT_SUCCESS;
}
Ejecución
$ gcc -std=c17 -Wall -pedantic -o it4 iteracion4.c
$ ./it4
0 1 2 3 4

😱

¿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 ints 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

double *pvector[];

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:

double (*var)[];

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
1
2
3
4
5
int i1 = 11;
int i2 = 21;
int i3 = 31;
int i4 = 41;
int *p2ints[] = {&i1, &i2, &i3, &i4};

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
int i1 = 11;
int i2 = 21;
int i3 = 31;
int i4 = 41;
int *p2ints[] = {NULL, NULL, NULL, NULL};

p2ints[0] = &i1;
p2ints[1] = &i2;
p2ints[2] = &i3;
p2ints[3] = &i4;

Estas variables se distribuyen en memoria como se muestra en la figura 5, a continuación.

Fig. 5: Esquema de memoria en la inicialización de las variables.

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:

Fig. 6: Ubicación de las variables en un diagrama de memoria.

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.

Fig. 7: Esquema de punteros.

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.

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define MAX_LENGTH 256

void strip_newline(char *s)
{
    char *p = NULL;

    if (NULL != s) {
        p = strrchr(s, '\n');

        if (NULL != p) {
            *p = '\0';
        }
    }
}

double maximo(double *ptrs[], size_t l)
{
    double m;

    m = *ptrs[0];
    while (--l) {
        if (*ptrs[l] > m) {
            m = *ptrs[l];
        }
    }
    return m;
}

int main (void)
{
    char input[MAX_LENGTH];
    char *perr = NULL;
    double d1 = 1.1e11;
    double d2 = 2.1e21;
    double d3 = 3.1e31;
    double d4 = 4.1e41;
    double *p2double[] = {NULL, NULL, NULL, NULL};
    double aux;

    p2double[0] = &d1;
    p2double[1] = &d2;
    p2double[2] = &d3;
    p2double[3] = &d4;

    for (size_t i = 0; i < sizeof(p2double) / sizeof(p2double[0]); ++i) {
        printf("Restan ingresar %li números\n", sizeof(p2double) / sizeof(p2double[0]) - i);
        printf("Por favor, ingrese un número: ");
        if (NULL == fgets(input, MAX_LENGTH, stdin)) {
            fprintf(stderr, "Error leyendo el %li-ésimo número\nNo hay posibilidad de reingresarlo.\n", i + 1);
            continue;
        }

        strip_newline(input);
        aux = strtod(input, &perr);
        if ('\0' != *perr) {
            fprintf(stderr, "\"%s\" no puede ser convertido a un número ¯\\_(ツ)_/¯\n", input);
            continue;
        }

        *p2double[i] = aux;
    }

    for (size_t i = 0; i < sizeof(p2double) / sizeof(p2double[0]); ++i) {
        printf("*p2double[%li] = %g\n", i, *p2double[i]);
    }

    aux = maximo(p2double, sizeof(p2double) / sizeof(p2double[0]));
    printf("y el máximo es: %g\n", aux);

    return EXIT_SUCCESS;
}

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:

float *matriz[] = {fila0, fila1, fila2, fila3};

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.

Fig. 8: Matriz formada como arreglo de punteros.

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.

#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define MAX_LENGTH 256

double traza(float **m, size_t filas, size_t columnas);
void identar(int i);
void imprimir_matriz(float **m, size_t filas, size_t columnas, int ident);
void strip_newline(char *s);

int main (void)
{
    char input[MAX_LENGTH];
    char *perr = NULL;
    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};
    float fila4[] = {51, 52, 53, 54, 55};
    float *matriz[] = {fila0, fila1, fila2, fila3, fila4};
    float aux = 9e23;

    for (size_t i = 0; i < 5; ++i) {
        for (size_t j = 0; j < 5; ++j) {
            printf("Restan ingresar %li números\n", 25 - i*5 - j);
            printf("Por favor, ingrese un número: ");
            if (NULL == fgets(input, MAX_LENGTH, stdin)) {
                fprintf(stderr, "Error leyendo el (%li, %li)-ésimo elemento\nNo hay posibilidad de reingresarlo.\n", i + 1, j + 1);
                continue;
            }

            strip_newline(input);
            aux = strtod(input, &perr);
            if ('\0' != *perr) {
                fprintf(stderr, "\"%s\" no puede ser convertido a un número ¯\\_(ツ)_/¯\n", input);
                continue;
            }

            matriz[i][j] = aux;
        }
    }

    imprimir_matriz(matriz, 5, 5, 0);

    aux = traza(matriz, 5, 5);
    printf("y su traza es: %g\n", aux);

    return EXIT_SUCCESS;
}

double traza(float **m, size_t filas, size_t columnas)
{
    double t;

    if (filas == columnas) {
        for (size_t i = 0; i < filas; ++i) {
            t += m[i][i];
        }
    }

    return t;
}

void imprimir_matriz(float **m, size_t filas, size_t columnas, int ident)
{
    identar(ident);
    puts("{");
    for (size_t i = 0; i < filas; ++i) {
        identar(ident + 4);
        printf("{%6.3f", m[i][0]);
        for (size_t j = 1; j < columnas; ++j) {
            printf(", %6.3f", m[i][j]);
        }
        puts("},");
    }
    identar(ident);
    puts("}");
}

void strip_newline(char *s)
{
    char *p = NULL;

    if (NULL != s) {
        p = strrchr(s, '\n');

        if (NULL != p) {
            *p = '\0';
        }
    }
}

void identar(int i)
{
    while (i-- > 0) {
        putchar(' ');
    }
}

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:

char *cadena = "Patricio";

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

char *nombre[] = {"Patricio", "Moreno"};

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:

1
2
3
4
5
6
7
8
9
ssize_t find(const char *items[], ssize_t l, const char *item)
{
    for (ssize_t i = 0; i < l; ++i) {
        if (!strcmp(items[i], item)) {
            return i;
        }
    }
    return -1;
}

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:

1
2
3
4
5
6
7
8
9
ssize_t find(char *items[], const char *item)
{
    for (ssize_t i = 0; NULL != items[i]; ++i) {
        if (!strcmp(items[i], item)) {
            return i;
        }
    }
    return -1;
}

Finalmente, haciendo un uso avanzado de los punteros, podriamos reescribir la función del siguiente modo:

1
2
3
4
5
6
7
8
9
char **find(char *items[], const char *item)
{
    for (char **iut = items; NULL != *iut; ++iut) {
        if (!strcmp(*iut, item)) {
            return iut;
        }
    }
    return NULL;
}

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 doubles:

meand.c
#include <stdlib.h>

double meand(double *v, size_t length)
{
    double mean = 0;

    for (size_t i = 0; i < length; ++i) {
        mean += v[i];
    }

    return mean / length;
}

Veamos qué ocurre al ejecutarla:

Test meand.c
#include <stdio.h>
#include <stdlib.h>

double meand(double *, size_t);

int main(void)
{
    double array[] = {34.2, 97.01, 0.0003, 1092.7, 5};

    printf("Mean: %.2f\n", meand(array, 5));

    return EXIT_SUCCESS;
}
Compilación y ejecución
$ 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
#include <stdio.h>
#include <stdlib.h>

double meand(double *, size_t);

int main(void)
{
    double array[5];

    printf("Mean: %.2f\n", meand(array, 0));

    return EXIT_SUCCESS;
}
Compilación y ejecución
$ gcc -std=c17 -Wall -pedantic -c -o meand.o meand.c
$ gcc -std=c17 -Wall -pedantic -c -o test_zero_meand.o test_zero_meand.c
$ gcc -std=c17 -Wall -pedantic    -o test_zero_meand test_zero_meand.o meand.o
$ ./test_zero_meand
Mean: -nan
Test meand.c con NULL
#include <stdio.h>
#include <stdlib.h>

double meand(double *, size_t);

int main(void)
{
    double *array = NULL;

    printf("Mean: %.2f\n", meand(array, 5));

    return EXIT_SUCCESS;
}
Compilación y ejecución
$ gcc -std=c17 -Wall -pedantic -c -o meand.o meand.c
$ gcc -std=c17 -Wall -pedantic -c -o test_null_meand.o test_null_meand.c
$ gcc -std=c17 -Wall -pedantic    -o test_null_meand test_null_meand.o meand.o
$ ./test_null_meand
Segmentation fault (core dumped)

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:

if (0 == length) { ... }

Y para ver si array es NULL, podríamos usar:

if (NULL == array) { ... }

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
#include <stdlib.h>

int meand(double *mean, double *v, size_t length)
{
    double _mean = 0;

    if ((NULL == mean) || (NULL == v)) {
        return -1;
    }

    if (0 == length) {
        return -2;
    }

    for (size_t i = 0; i < length; ++i) {
        _mean += v[i];
    }

    *mean = _mean / length;

    return 0;
}

Cuyo “test” completo es:

meand_ok.c
#include <stdio.h>
#include <stdlib.h>

int meand(double *mean, double *v, size_t l);

int main(void)
{
    double array[] = {34.2, 97.01, 0.0003, 1092.7, 5};
    double mean;

    puts("Prueba con valores típicos:");
    if (0 == meand(&mean, array, 5)) {
        printf("\tMean: %.2f\n", mean);
    } else {
        fprintf(stderr, "Ocurrió un error inesperado\n");
    }

    puts("Prueba con arreglo de largo 0:");
    if (0 == meand(&mean, array, 0)) {
        printf("\tMean: %.2f\n", mean);
    } else {
        fprintf(stderr, "\tNo se pudo calcular la media (posiblemente el largo 0 tenga algo que ver)\n");
    }

    puts("Prueba con arreglo NULL:");
    if (0 == meand(&mean, NULL, 5)) {
        printf("\tMean: %.2f\n", mean);
    } else {
        fprintf(stderr, "\tNo se pudo calcular la media (posiblemente el puntero nulo tenga algo que ver)\n");
    }

    return EXIT_SUCCESS;
}
Compilación y ejecución
$ 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
1
2
3
4
5
6
7
8
9
#pragma once

typedef enum {
    ST_OK,
    ST_ERR_NULL_PTR,
    ST_ERR_LZERO_ARRAY,
    ST_ERR_INVALID_ARG,
    ST_ERR_UNKNOWN,
} status_t;

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:

status.h
1
2
3
4
5
6
7
8
9
#pragma once

typedef enum {
    ST_OK,
    ST_ERR_NULL_PTR,
    ST_ERR_LZERO_ARRAY,
    ST_ERR_INVALID_ARG,
    ST_ERR_UNKNOWN,
} status_t;
meand.h
1
2
3
4
5
6
#pragma once
#include "status.h"

#include <stdlib.h>

status_t meand(double *mean, double *v, size_t length);
meand.c
#include "meand.h"
#include "status.h"

#include <stdlib.h>

status_t meand(double *mean, double *v, size_t length)
{
    double _mean = 0;

    if ((NULL == mean) || (NULL == v)) {
        return ST_ERR_NULL_PTR;
    }

    if (0 == length) {
        return ST_ERR_LZERO_ARRAY;
    }

    for (size_t i = 0; i < length; ++i) {
        _mean += v[i];
    }

    *mean = _mean / length;

    return ST_OK;
}
test.c
#include "status.h"
#include "meand.h"

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    double array[] = {34.2, 97.01, 0.0003, 1092.7, 5};
    double mean;

    puts("Prueba con valores típicos:");
    if (ST_OK != meand(&mean, array, 5)) {
        fprintf(stderr, "Ocurrió un error inesperado\n");
    } else {
        printf("\tMean: %.2f\n", mean);
    }

    puts("Prueba con arreglo de largo 0:");
    if (ST_OK != meand(&mean, array, 0)) {
        fprintf(stderr, "\tNo se pudo calcular la media (posiblemente el largo 0 tenga algo que ver)\n");
    } else {
        printf("\tMean: %.2f\n", mean);
    }

    puts("Prueba con arreglo NULL:");
    if (ST_OK != meand(&mean, NULL, 5)) {
        fprintf(stderr, "\tNo se pudo calcular la media (posiblemente el puntero nulo tenga algo que ver)\n");
    } else {
        printf("\tMean: %.2f\n", mean);
    }

    return EXIT_SUCCESS;
}
$ 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í.


Última actualización: March 15, 2023