Los punteros, independientemente del tipo de datos con el que se declaren, no son más que posiciones de memoria. Solo partiendo de esta afirmación se podrán entender los punteros. Y es que cualquier tipo de dato, en el lenguaje que sea, necesita tener un espacio lógico dentro la memoria donde almacenarse. Ya sea una variable de tipo entera (int), un carácter (char), o cualquier otro tipo de dato, incluyendo los TAD, tendrá asignada una posición de memoria y el espacio necesario para el tipo de dato que es.
Por ejemplo, si se declara una variable de tipo entero, dependiendo de la arquitectura con la que se ejecute el programa, ocupará una cantidad de bytes y tendrá asociada una posición de memoria que será accesible a partir del nombre de la variable. Eso quiere decir que, cuando se llame a la variable dentro del código, en realidad se le estará diciendo que vaya a la posición de memoria vinculada a ese identificador (ese nombre de variable) y lea tantos bytes como ocupa ese tipo de dato. Se podría afirmar entonces, que el programador no maneja la memoria que usa el programa, sino que es el propio lenguaje el que lo hace.
Funcionamiento de los punteros
No obstante, hay lenguajes que permiten al programador gestionar por sí mismo la memoria que usa el programa. Uno de estos lenguajes es C y es justamente en este punto donde entran en juego los punteros. Ya se habló del uso de la memoria dinámica en C con punteros en el artículo Introducción a la memoria dinámica: malloc y realloc, así que no se hará una mención especial, pero sí que se recomienda su lectura para complementar la de este artículo.
Por lo tanto, volviendo al tema de este artículo, ¿cómo hay que declarar las variables para poder gestionar en todo momento la memoria de estas? La respuesta se encuentra en los punteros, usando como sintaxis el asterisco justo antes del nombre de la variable, el compilador entenderá que esa variable no debe ser tratada como las anteriores, si no simplemente contendrá una posición de memoria a la cual acceder, más no tendrá una memoria asignada hasta que no se realice con algunas de las tres funciones para hacerlo de la biblioteca stdlib.h: malloc, calloc o realloc.
#include <stdio.h>
#include <stdlib.h>
int main()
{
/*
Esta variable permite almacenar
posiciones de memoria, pero en
este escenario no tiene memoria
reservada.
*/
int *num;
/*
Ahora se puede almacenar un número
entero dentro de la variable.
*/
num = (int*)malloc(sizeof(int));
/*
La posición 0 tiene asignada la
memoria suficiente para almacenar
un número entero.
*/
num[0] = 1;
/*
Ahora se permiten almacenar dos
números enteros conservando el
contenido anterior con realloc.
*/
num = (int*)realloc(num, sizeof(int) * 2);
/*
Ahora la posición 1 también tiene
memoria suficiente y se mantiene
el contenido de la posición 0.
*/
num[1] = 2;
return 0;
}
Como ya se ha podido ver en el ejemplo, una de las ventajas de los punteros es precisamente tener la capacidad de almacenar múltiples elementos (del dato que sea ese puntero) gestionando siempre la memoria, asignándole o desasignándole según las necesidades. Esta es una de las razones por las que C (y también C++) se considera uno de los lenguajes más eficientes: por su capacidad de gestionar la memoria cuando el programador considera que hay que hacerlo (obviamente para ello hay que tener la certeza de cuándo hacerlo, si no, se incurre en errores de memoria).
Entonces, ¿cómo hay que controlar a los punteros? En primer lugar, hay que destacar que, cuando un puntero se acaba de declarar y no va a contener, por ahora, ninguna dirección de memoria, es necesario que estos se inicialicen a un valor que determine que no apuntan a ningún lado (puesto que los punteros se llaman así porque apuntan a una dirección de memoria, como ya se ha comentado). En este caso, se suele usar la palabra NULL. De esta forma, el compilador entiende que no apunta a ninguna posición de memoria. No obstante, esta palabra tiene algo de controversia, porque realmente el valor real de NULL es 0 y realmente no debería ser ese valor, puesto que 0 también podría ser el valor de una variable entera (int). Para ello, en versiones actuales de lenguajes como C++, existe otra palabra reservada que describe mejor esta situación: nullptr. Una vez se quiera asignar memoria al puntero, entonces se sobreescribirá ese NULL.
Por lo tanto, se ha podido ver como los punteros no son más que direcciones de memoria que requieren de una reserva de esta con alguna de las funciones ya citadas y permiten almacenar la cantidad de elementos que se quiera del dato que se haya declarado. Sin embargo, ¿se puede pasar un puntero como parámetro de una función? La respuesta es afirmativa y justamente es uno de sus puntos fuertes.
Paso por referencia
Cuando se pasa un valor por parámetro a una función, si este es manipulado, no se verá afectado fuera de esta. Es decir, si una función A pasa como parámetro una variable con el valor 5 y una función B lo modifica, cuando el código vuelva a la función A, la variable pasada como parámetro seguirá teniendo el valor anterior, 5. No obstante, sí que existe una manera de poder conservar de forma correcta una modificación en una función: con los punteros. De hecho, no es casual, porque si se piensa conceptualmente, no se estará cambiando el contenido del puntero como tal, puesto que solo es una dirección de memoria y seguirá siendo la misma, sino que se modifica directamente el contenido que hay en esa dirección de memoria. Hecho que va a permitir conservar los cambios.
#include <stdio.h>
#include <stdlib.h>
void multiplicador(int num)
{
num = num*2;
}
void multiplicadorReferencia(int *num)
{
*num = *num*2;
}
int main()
{
int num = 2;
// Aquí el valor de num vale 2
printf("Num = %d\n", num);
multiplicador(num);
// Aquí el valor de num sigue valiendo 2
printf("Num = %d\n", num);
multiplicadorReferencia(&num);
// Aquí el valor de num vale 4
printf("Num = %d\n", num);
}
En el código anterior se puede apreciar justamente la ventaja de los punteros. Pero hay varios elementos que hay que tratar para poder comprenderlo en su totalidad. Se ha comentado que son los punteros los que permiten mantener una modificación sobre su contenido. Sin embargo, la variable del código anterior llamada num no es un puntero, es una variable de tipo entero sin ser puntero. Esto es debido a que la función main será la dueña, por llamarlo de alguna forma, de la variable num. Al no ser un puntero, permite justamente mostrar la peculiaridad de modificar una variable que no es un puntero. Es por ello que cuando se pasa la variable con la función llamada multiplicador se pasa como un parámetro normal. En cambio, con la función multiplicadorReferencia aparece una nueva sintaxis, el ampersand.
De la misma forma que se utiliza un asterisco para declarar variables que sean punteros, se utiliza el ampersand para acceder a su dirección de memoria y, de esta manera, poder convertirla en puntero. A pesar de que num no se haya declarado como puntero y no tenga una reserva de memoria hecha, tal como se había comentado al inicio del artículo, todas las variables que no son puntero tienen memoria asignada de manera automática. Por ende, se puede acceder a la posición de memoria en la que se encuentran convirtiendo esa variable a un puntero con la sintaxis de añadirle el ampersand delante.
Por otro lado, la función multiplicadorReferencia tiene como parámetro una variable con asterisco, es decir, está esperando un puntero. Cuando una función tiene un asterisco en uno de sus parámetros es que está esperando una dirección de memoria del tipo de dato que tiene declarado. Justamente, al llamar esta función, se ha convertido una variable de tipo entero sin ser puntero, a un puntero de tipo entero. Por lo tanto, el tipo de dato es el esperado y, por el hecho de ser un puntero se accederá a su posición de memoria y el valor será conservado al salir de ella. Por esta razón, una función modifica su valor y la otra no.
Finalmente, hay que explicar porque el código de la función multiplicadorReferencia utiliza el asterisco delante de la variable (línea 6). Como ya se ha explicado en el párrafo anterior, esta función recibe un puntero, es decir, una dirección de memoria. Si el código intentará multiplicar por 2 la dirección de memoria, se obtendría un resultado que no es el esperado, ya que se estaría modificando su dirección de memoria. Tal como se había comentado, si se recibe un puntero, la información que realmente interesa almacenar está en la dirección de memoria que tiene ese puntero. Con lo cual, hay que acceder a esa posición de memoria para obtener el valor deseado. Es decir, la variable num podría contener un valor como el siguiente: 0x12f215a. Pero el número 2, el que nos interesa, está dentro de esa dirección de memoria. Para poder acceder a ella hay que usar el asterisco justo delante del nombre de la variable.
Para resumirlo, se podría decir que usar el asterisco delante de una variable tipo puntero ya declarada es acceder al valor que contiene, sin embargo, poner un ampersand delante de una variable que no es puntero, es acceder a la dirección de memoria, es decir, proporcionar un dato de tipo puntero. Con lo cual, se podría decir, para que se entienda correctamente, aunque no es exactamente así, que la operación ampersand y asterisco son inversas. En el siguiente ejemplo se mostrará un código que realmente no tiene ningún tipo de interés, más que el de ver que realmente si se aplica una operación para obtener su dirección de memoria y después acceder al valor de esa dirección de memoria, se obtiene lo mismo que directamente sin poner nada.
#include <stdio.h>
#include <stdlib.h>
int main()
{
int num = 2;
// Muestra como valor el 2
printf("Num = %d\n", num);
// También muestra como valor el 2
printf("Num = %d\n", *&num);
}
Conclusión
En resumen, los punteros son una herramienta muy potente que incluyen lenguajes como C (otros como Java directamente todas las variables se tratan como punteros, menos los tipos de datos primitivos). Sin embargo, es muy necesario entender lo que realmente representan estos. Por ello, en este artículo se ha pretendido explicar con ejemplos simples su funcionamiento. No obstante, os recomendamos cursar el curso de Prácticas de programación donde se trabajan a fondo los punteros incluso utilizando tipos abstractos de datos como la lista enlazada, la cola, la pila, etc.).
Más información
Si te ha gustado este artículo, te proponemos unos enlaces con los que profundizar sobre el paso por referencia y el acceso a los elementos de una estructura de datos pasada por referencia.
- Punteros: uso de los punteros en C y C++.
- Referencia: uso de los parámetros por referencia.
- Punteros en TAD: código de ejemplo de punteros en TAD.
Si consideras que hay algún error en este artículo háznoslo saber mediante nuestro correo [email protected].