08. Apuntadores: problemas comunes

Publicado por

Hemos alcanzado la nota final y, durante esta primera temporada, has aprendido que los apuntadores ofrecen diversas ventajas para que logremos crear programas más complejos; sin embargo, esto no quiere decir que estemos libres de problemas, un mal manejo de los apuntadores puede llevarnos a tener verdaderos quebraderos de cabeza y es mejor estar preparados.

En esta ocasión, y tomando como base lo que se ha expuesto hasta el momento, daremos un vistazo a los problemas más comunes en el uso de estos y las soluciones que nos evitarán malos ratos.


Error en la declaración

En la nota dos aprendiste cómo declarar apuntadores y te mencioné un error muy común cuando se desean declarar varios apuntadores en una misma línea. El problema surge porque muchas personas se olvidan de colocar el operador * en cada identificador de cada variable que llegan a crear.

// Forma correcta de declarar varios apuntadores.
double *num1, *num2, *num3;

Se llega a tener la idea errónea de que con solo colocar el tipo de dato y el asterisco al principio, se logrará que todas las variables declaradas sean del tipo apuntador pero no es así. No obstante, es posible tomar esta idea y convertirla en una solución elegante; lo que tendríamos que hacer es, para evitar tener que escribir un asterisco al lado de cada identificador, definir un nuevo tipo de dato y usarlo para crear varios apuntadores:

typedef double* PtrDouble;
PtrDouble num1, num2, num3; // Será lo mismo que el ejemplo anterior.

Cabe mencionar que lo recomendable es darle a cada variable su propia línea con el propósito de hacer más legible nuestro código; sin embargo, nada te impide crear los apuntadores que desees sobre la misma instrucción.


Valor basura

Tal y como lo discutimos en su momento, los apuntadores, al ser declarados, guardan un valor no definido o basura; esto sucede debido a que, al reservarse espacio en memoria para dicho apuntador, este espacio contiene los restos de lo que alguna vez se escribió ahí. Si llegásemos a leer el contenido de dicho apuntador, este interpretará los restos de alguna forma y nos dará un valor completamente desconocido.

¿Cuál es el problema con lo anterior? Que podríamos estar afectando, sin estar plenamente conscientes de ello, alguna localidad de memoria ajena a la que queríamos y lograr que nuestro programa nos arroje un error o interrumpa totalmente su ejecución (en el mejor de los casos).

// Aunque no está inicializado, está guardando un valor.
char *ptrConBasura;

// ¡Posible error! Queremos asignar un caracter a algo que desconocemos.
*ptrConBasura = 'A';

Pudiera darse el caso de que el espacio ocupado por nuestro apuntador contenga un valor que resulte válido para nuestro programa, pero jamás tendremos dicha garantía; por tanto, lo que debemos hacer es siempre inicializar nuestros apuntadores a NULL para que nos resulte más sencillo verificar que los estamos utilizando de manera apropiada.

char *ptrSeguro = NULL;

if (ptrSeguro == NULL) {
  // Informemos que hay un error con el apuntador.
}
else {
  // Nuestro apuntador puede ser usado sin problemas.
}

Escribir este tipo de bloques de prueba puede llegar a ser muy tedioso pero hará que tu programa sea más robusto y que los errores se reduzcan. Aún así, existen otras opciones que podrías utilizar para checar este tipo de problemas, solo basta aventurarse a investigar un poco más.


Apuntadores vs arreglos

Directamente desde la nota cuatro nos llega la información de que podemos tratar al identificador de un arreglo como si fuera un apuntador hacia el primer elemento de la colección. ¿Esto quiere decir que los arreglos y los apuntadores son lo mismo? Pues a decir verdad, no. Tomemos el siguiente ejemplo como referencia y vayamos viendo qué diferencia surge entre arreglos y apuntadores:

char lista[4] = {'a', 'b', 'c', 'd'};
char *ptrLista = lista;

El identificador de nuestro arreglo cumple su función, nos otorga la dirección de memoria del primero de sus elementos. Esta dirección la almacenamos en nuestra variable apuntador y, a partir de ahí, podemos hacer lo que queramos. El punto clave en lo que acabamos de decir es que no podemos tratar al identificador del arreglo como un apuntador per se sino como un valor. Veámoslo de la siguiente manera:

// Imagina que la dirección del primer elemento de
// nuestro arreglo "lista" es el número 0xFFFF, por tanto,
// la instrucción en la línea 2 del ejemplo anterior sería
// prácticamente lo mismo que ver lo siguiente:
char *ptrLista = 0xFFFF;

// Si quisiéramos cambiar algo usando el identificador,
// estariamos realizando algo como esto:
0xFFFF = NULL;

Como puedes apreciar, la última declaración no tiene sentido, por tanto, se nos marcaría un error de sintaxis por parte de nuestro compilador.

El hecho de usar el identificador de un arreglo es una comodidad para no tener que usar el operador &. Si quisiéramos utilizarlo tendríamos que ser muy cuidadosos y realizarlo así:

char *ptrLista = &lista[0];

Si por alguna razón olvidáramos colocar los corchetes y el índice, estaríamos obteniendo la dirección del arreglo en su totalidad. Esto último sería considerado un error dependiendo de qué es lo que estemos realizando.


Memoria fuera de los límites del arreglo

Por último, pero no menos importante, hablemos sobre el hecho de acceder a los elementos de un arreglo haciendo uso de índices. El índice a utilizar, para nuestro punto de vista, estaría restringido a ir entre 0 (el inicio del arreglo) y n-1 (el final de nuestro arreglo); sin embargo, nada en el mundo nos impide a que utilicemos cualquier número, incluidos los negativos, por tanto, debemos tener bastante cuidado en siempre verificar que nuestro índice se encuentre dentro del rango adecuado.

¿Qué ocurre si utilizamos un índice fuera de rango? Simplemente accedemos a alguna localidad de memoria de la cual no sabemos necesariamente a qué hace referencia. En el caso, por ejemplo, de que tuviéramos dos arreglos definidos y que estos se encontraran de manera contigua en la memoria, un índice fuera de límite podría hacer que accedamos a los campos del otro arreglo y lo modifiquemos sin querer.

int listaNum1[5] = {1, 2, 3, 4, 5};
int listaNum2[5] = {0, 9, 8, 7, 6};

// Utilizamos un índice negativo en el segundo arreglo
listaNum2[-2] = 11;

// Si suponemos que ambos arreglos se encuentran uno detrás
// del otro, puedes ver que se hizo un cambio en el primer arreglo:
int i;
for (i = 0; i < 5; i++) {
  printf("%d\n", listaNum1[i]);
}

// SALIDA
// 1
// 2
// 3
// 11
// 5


Para terminar

Como se dijo al principio, hemos alcanzado la nota que concluye esta temporada; esto no quiere decir que hemos llegado al final puesto que aún quedan varias cosas por conocer, entre ellas se encuentra el cómo obtener memoria de forma dinámica, es decir, pedir y liberar memoria mientras nuestro programa se ejecuta.

Espero que estas notas te hayan ayudado lo suficiente para que sientas más confianza en utilizar apuntadores y que se hayan despejado algunas de las confusiones que siempre surgen cuando uno se aventura a tratar con la memoria de nuestra computadora.
Cualquier comentario o sugerencia que quieras exponer es más que bienvenida en los comentarios. Nos vemos en la siguiente entrega, ¡hasta entonces!

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *