3.4.4. Introducción a punteros

Siempre que se ejecuta un programa, se carga primero (típicamente desde disco) a la memoria del ordenador. De este modo, todos los elementos del programa se ubican en algún lugar de la memoria. La memoria se representa normalmente como series secuenciales de posiciones de memoria; normalmente se hace referencia a estas localizaciones como bytes de ocho bits, pero realmente el tamaño de cada espacio depende de la arquitectura de cada máquina particular y se llamada normalmente tamaño de palabra de la máquina. Cada espacio se puede distinguir unívocamente de todos los demás espacios por su dirección. Para este tema en particular, se establecerá que todas las máquinas usan bytes que tienen direcciones secuenciales, comenzando en cero y subiendo hasta la cantidad de memoria que posea la máquina.

Como el programa reside en memoria mientras se está ejecutando, cada elemento de dicho programa tiene una dirección. Suponga que empezamos con un programa simple:

//: C03:YourPets1.cpp
#include <iostream>
using namespace std;

int dog, cat, bird, fish;

void f(int pet) {
  cout << "pet id number: " << pet << endl;
}

int main() {
  int i, j, k;
} ///:~

Listado 3.13. C03/YourPets1.cpp


Cada uno de los elementos de este programa tiene una localización en memoria mientras el programa se está ejecutando. Incluso las funciones ocupan espacio. Como verá, se da por sentado que el tipo de un elemento y la forma en que se define determina normalmente el área de memoria en la que se ubica dicho elemento.

Hay un operador en C y C++ que permite averiguar la dirección de un elemento. Se trata del operador &. Sólo hay que anteponer el operador & delante del nombre identificador y obtendrá la dirección de ese identificador. Se puede modificar YourPets1.cpp para mostrar las direcciones de todos sus elementos, del siguiente modo:

//: C03:YourPets2.cpp
#include <iostream>
using namespace std;

int dog, cat, bird, fish;

void f(int pet) {
  cout << "pet id number: " << pet << endl;
}

int main() {
  int i, j, k;
  cout << "f(): " << (long)&f << endl;
  cout << "dog: " << (long)&dog << endl;
  cout << "cat: " << (long)&cat << endl;
  cout << "bird: " << (long)&bird << endl;
  cout << "fish: " << (long)&fish << endl;
  cout << "i: " << (long)&i << endl;
  cout << "j: " << (long)&j << endl;
  cout << "k: " << (long)&k << endl;
} ///:~

Listado 3.14. C03/YourPets2.cpp


El (long) es una molde. Indica «No tratar como su tipo normal, sino como un long». El molde no es esencial, pero si no existiese, las direcciones aparecerían en hexadecimal, de modo que el moldeado a long hace las cosas más legibles.

Los resultados de este programa variarán dependiendo del computador, del sistema operativo, y de muchos otros tipos de factores, pero siempre darán un resultado interesante. Para una única ejecución en mi computador, los resultados son como estos:

f(): 4198736
dog: 4323632
cat: 4323636
bird: 4323640
fish: 4323644
i: 6684160
j: 6684156
k: 6684152

Se puede apreciar como las variables que se han definido dentro de main() están en un área distinta que las variables definidas fuera de main(); entenderá el porque cuando se profundice más en el lenguaje. También, f() parece estar en su propia área; el código normalmente se separa del resto de los datos en memoria.

Otra cosa a tener en cuenta es que las variables definidas una a continuación de la otra parecen estar ubicadas de manera contigua en memoria. Están separadas por el número de bytes requeridos por su tipo de dato. En este programa el único tipo de dato utilizado es el int, y la variable cat está separada de dog por cuatro bytes, bird está separada por cuatro bytes de cat, etc. De modo que en el computador en que ha sido ejecutado el programa, un entero ocupa cuatro bytes.

¿Qué se puede hacer con las direcciones de memoria, además de este interesante experimento de mostrar cuanta memoria ocupan? Lo más importante que se puede hacer es guardar esas direcciones dentro de otras variables para su uso posterior. C y C++ tienen un tipo de variable especial para guardar una dirección. Esas variables se llaman punteros.

El operador que define un puntero es el mismo que se utiliza para la multiplicación: *. El compilador sabe que no es una multiplicación por el contexto en el que se usa, tal como podrá comprobar.

Cuando se define un puntero, se debe especificar el tipo de variable al que apunta. Se comienza dando el nombre de dicho tipo, después en lugar de escribir un identificador para la variable, usted dice «Espera, esto es un puntero» insertando un asterisco entre el tipo y el identificador. De modo que un puntero a int tiene este aspecto:

int* ip; // ip apunta a una variable int

La asociación del * con el tipo parece práctica y legible, pero puede ser un poco confusa. La tendencia podría ser decir «puntero-entero» como un si fuese un tipo simple. Sin embargo, con un int u otro tipo de datos básico, se puede decir:

int a, b, c;

así que con un puntero, diría:

int* ipa, ipb, ipc;

La sintaxis de C (y por herencia, la de C++) no permite expresiones tan cómodas. En las definiciones anteriores, sólo ipa es un puntero, pero ipb e ipc son ints normales (se puede decir que «* está mas unido al identificador»). Como consecuencia, los mejores resultados se pueden obtener utilizando sólo una definición por línea; y aún se conserva una sintaxis cómoda y sin la confusión:

int* ipa;
int* ipb;
int* ipc;

Ya que una pauta de programación de C++ es que siempre se debe inicializar una variable al definirla, realmente este modo funciona mejor. Por ejemplo, Las variables anteriores no se inicializan con ningún valor en particular; contienen basura. Es más fácil decir algo como:

int a = 47;
int* ipa = &a;

Ahora tanto a como ipa están inicializadas, y ipa contiene la dirección de a.

Una vez que se inicializa un puntero, lo más básico que se puede hacer con Él es utilizarlo para modificar el valor de lo que apunta. Para acceder a la variable a través del puntero, se dereferencia el puntero utilizando el mismo operador que se usó para definirlo, como sigue:

*ipa = 100;

Ahora a contiene el valor 100 en vez de 47.

Estas son las normas básicas de los punteros: se puede guardar una dirección, y se puede utilizar dicha dirección para modificar la variable original. Pero la pregunta aún permanece: ¿por qué se querría cambiar una variable utilizando otra variable como intermediario?

Para esta visión introductoria a los punteros, podemos dividir la respuesta en dos grandes categorías:

  1. Para cambiar «objetos externos» desde dentro de una función. Esto es quizás el uso más básico de los punteros, y se examinará más adelante.

  2. Para conseguir otras muchas técnicas de programación ingeniosas, sobre las que aprenderá en el resto del libro.