Punteros: Qué son y cómo trabajar con ellos

Última actualización: 28 de julio de 2020.

Definiciones básicas


Al trabajar con el lenguaje C++, el empleo de punteros resulta inevitable, ya sea para introducir semántica de referencia en nuestros códigos, alojar objetos en el free store, emplear polimorfismo dinámico u operar con estructuras dinámicas de datos y sus iteradores. Familiarizarse con este tipo de variables, hasta el punto de convertir su manipulación en una tarea natural para el programador, requiere esfuerzo y numerosas horas de práctica. Sin embargo, los conceptos básicos involucrados en su aprendizaje resultan extremadamente simples.

Un puntero es una variable que almacena la dirección en memoria de otro objeto. Como tal, el espacio ocupado en memoria por un puntero (independientemente del tipo de objeto que referencie) coincide con el número de bytes necesario para especificar una dirección de memoria: 4 bytes en la arquitectura x86, 8 bytes en la arquitectura x86-64.

La sintaxis básica para definir un puntero (en el ejemplo, referenciando a un entero no-constante) es:

   auto n = int{0}; // n es un entero    int* p = &n;  // p es un puntero a entero que almacena la dirección de n

De forma alternativa, el tipo de puntero podría inferirse mediante la palabra clave auto (puedes encontrar más información en este post):

   auto p = &n; // p es de tipo deducido: int*

Aquí, p es un puntero de tipo int* (es decir, un puntero a entero) que referencia al entero n, almacenando su dirección en memoria. Observemos, en particular, el empleo del operador unario dirección-de (&) para obtener la dirección de n e inicializar p con ella.

La siguiente operación de inserción

   std::cout << p; // imprime la dirección en memoria de n

imprime en la salida estándar la dirección en memoria de n en formato hexadecimal. Para poder acceder al objeto referenciado por el puntero y mostrar su valor en consola, debemos utilizar el operador unario de indirección o desreferencia (*). Siendo p un puntero a un tipo cualquiera, *p retorna un lvalue que referencia al objeto apuntado por p. Así, la operación

   std::cout << *p; // puntero desreferenciado: imprime 0

imprime, ahora sí, el valor numérico almacenado en n (en este caso, cero). De igual forma, la asignación

   *p = 1; // el valor de n es ahora 1

reescribe el valor del objeto apuntado (el entero n) como la unidad. De imprimirse a continuación el entero, obtendríamos dicho nuevo valor en la salida estándar:

   std::cout << n; // ahora imprime 1


Sintaxis


Si el entero del ejemplo anterior fuese declarado constante, el puntero debería ser definido de forma consecuente para permitir únicamente operaciones de lectura a través de él:

   auto const cn = int{0}; // cn es un entero constante    int const* p = &cn;  // p es un puntero a entero constante

En efecto, la siguiente declaración incorrecta del puntero conduciría a un error de compilación debido a una conversión indebida de tipos:

   int* p = &cn; // error: invalid conversion from 'const int*' to 'int*'

Podríamos servirnos nuevamente de la inferencia automática de tipos para obtener automáticamente la declaración correcta, si bien a costa de dificultar la comprensión del código:

   auto p = &cn; // Ok, p es de tipo deducido: int const*

La variable p es un puntero a objeto constante de tipo int const* (en Inglés leeríamos, de derecha a izquierda, pointer-to-constant-int).

Si deseamos referenciar una variable no constante a través de un puntero con el fin de realizar únicamente operaciones de lectura, pero no de escritura, definiremos un puntero a objeto constante según un esquema análogo al anterior:

   auto n = int{0};  // n es un entero no-constante    int const* p = &n; // podemos leer el valor de n a través de p, pero no modificarlo

o, equivalentemente (el asterisco es obligatorio en este caso, al no ser la variable originalmente constante):

   auto const* p = &n; // p es de tipo deducido: int const*

Así, el valor de n no puede ser modificado a través del puntero p.

No debemos confundir la sintaxis anterior con

   auto n = int{0};  // n es un entero no-constante    int* const p = &n; // p es un puntero constante a un entero no-constante

o equivalentemente (sin asterisco):

   auto const p = &n;

que define un puntero constante a un entero no-constante (constant-pointer-to-int). El puntero no puede ser reasignado, pero permite operaciones de lectura y escritura sobre el entero referenciado.

Finalmente, un puntero constante a un entero constante se definiría como int const* const p (constant-pointer-to-constant-int) o, equivalentemente, auto const* const p.


Punteros a estructuras o clases. Acceso a la interfaz pública


Consideremos un puntero que apunte a un objeto de una estructura o clase, como en el siguiente ejemplo:

struct Student {     std::string name;     double grade_1{}, grade_2{}, grade_3{}; // las calificaciones son nulas por defecto     auto average() const { return (grade_1 + grade_2 + grade_3)/3.0; } }; // ... auto s = Student{"James Kerry", 6.0, 8.0, 9.5}; Student* p = &s;

Para acceder a un miembro cualquiera de la estructura a través del puntero p, por ejemplo para corregir la segunda calificación del estudiante e imprimir posterioremente su nota media final, podemos utilizar el operador de indirección * en la forma explicada anteriormente:

   (*p).grade_2 = 8.5; std::cout << (*p).average(); // imprime 8.0

La sintaxis anterior puede simplificarse, sin embargo, mediante el empleo del operador flecha de acceso (->) en la forma siguiente (compárese con el código anterior):

   p->grade_2 = 8.5; std::cout << p->average(); // imprime 8.0

El significado de este operador es claro: para poder acceder a la interfaz pública de Student, es necesario saltar el grado de indirección que hemos creado entre el puntero y el objeto al que referencia.

Así pues, y con carácter general, dado un puntero p a una estructura o clase, las expresiones (*p).member y p->member (donde member representa cualquier dato o función miembro accesible) pueden considerarse equivalentes, salvo que dicha estructura o clase sobrecargue los operadores operator* y operator-> con una semántica inesperada.


Puntero nulo


Un puntero sin asignar no referencia a un objeto válido, de manera que su desrefencia dará lugar a comportamiento indefinido (undefined behavior). Podemos indicar explícitamente que un puntero no apunta a un objeto válido mediante la palabra clave nullptr (puntero nulo):

int* p = nullptr;

Se recomienda el uso de nullptr frente al valor numérico 0 (incluida la macro NULL), muy habitual en código previo al estándar C++11.

El puntero nulo se emplea normalmente para indicar la imposibilidad de realizar ciertas operaciones o para establecer la posición final de iteración a través de estructuras de datos de tamaño variable, entre otros muchos posibles usos.


Punteros empleados como parámetros en funciones


Los punteros pueden emplearse para implementar el paso por referencia de argumentos a funciones. Su uso se recomienda siempre que la ausencia de argumento sea una opción válida a tomar en cuenta. Por ejemplo, consideremos la siguiente función czstring_to_string, que convierte cadenas de caracteres con terminación nula propias del lenguaje C en objetos std::string [1]. El tipo czstring es aquí un mero alias para char const* que explicita la intencionalidad de la función. El código debe asumir la posibilidad de que el puntero p sea nulo, en cuyo caso se opta por retornar un string vacío:

   auto czstring_to_string(czstring p) -> std::string    {       return p? std::string{p} : std::string{};     }

Si tales comprobaciones no son necesarias, se recomienda emplear las referencias propias de C++, las cuales no pueden ser nulas:

   void f(std::vector<int>& v) // v es un alias para el vector pasado como argumento    {       // la función modifica el vector de alguna forma...    }


Aritmética de punteros


En una serie de posts anterior dedicada a la gestión de la memoria, explicamos que una expresión de tipo new devuelve un puntero al objeto recién alojado en el free store:

Student* p = new Student{ .name = "Sarah Cole", .grade_1 = 6.0, .grade_2 = 8.5, .grade_3 = 9.5 }; // empleamos el objeto hasta que ya no sea necesario... delete p;

Aquí, el puntero p (de tipo Student*) se aloja en la pila y apunta a un objeto de tipo Student ubicado en el free store. El acceso a los datos y funciones miembro públicas de dicha estructura puede realizarse, como ya hemos explicado, a través del operador ->. Por supuesto, una vez finalizadas todas las acciones sobre el objeto, hemos de destruirlo y liberar la memoria mediante una expresión de tipo delete. Este ejemplo tiene una intención puramente ilustrativa, pues nunca deberíamos utilizar punteros tradicionales como agentes propietarios de forma no-encapsulada. En su lugar, deberíamos apoyarnos en la técnica RAII a través del empleo de, por ejemplo, punteros inteligentes.

Como sabemos, es también posible crear un array de objetos de tipo Student, ubicados en direcciones contiguas de memoria, a través de una expresión new[]:

Student* p = new Student[100]; // array de 100 students // empleamos el array hasta que ya no sea necesario... delete[] p;

El puntero p referencia al primer objeto del array, mientras que el puntero (p + i), donde i es un entero positivo o nulo, referencia al elemento i-ésimo del array (véase la figura inferior).


Así, si deseamos modificar la primera calificación obtenida por el quinto alumno en nuestra lista (es decir, el correspondiente al índice de acceso 4), podemos emplear una cualquiera de las siguientes expresiones:

   (*(p + 4)).grade_1 = 10.0;

equivalente a

   (p + 4)->grade_1 = 10.0;

Ahora bien, atendiendo al hecho de que la dirección almacenada en el puntero p coincide con la dirección de inicio del array, podemos también utilizar la indexación habitual para matrices de C y C++ y escribir:

   p[4].grade_1 = 10.0;

Esta última sintaxis es la más conveniente por razones evidentes. Conviene entonces imaginar al puntero p como una flecha que apunta a la casilla número 0 del array, al puntero (p + 1) como una flecha que señala la casilla número 1, y así sucesivamente (véase la figura superior). Los objetos realmente contenidos en las casillas son entonces p[0] (o, equivalentemente, *p), p[1] (es decir, *(p + 1)), p[2] (es decir, *(p + 2)), etcétera.

Observemos, finalmente, que la diferencia entre punteros para el array anterior nos proporciona el número de elementos entre los objetos por ellos apuntados (un puntero tradicional constituye el ejemplo más simple de iterador de acceso aleatorio):

   auto q = p + 4;  // q (de tipo Student*) apunta al quinto estudiante, p[4]     std::cout << q - p; // imprime 4 


Referencias bibliográficas:
  1. C++ Core Guidelines - https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rf-ptr-ref
  2. Stroustrup B., The C++ Programming Language. Addison-Wesley, 4th Edition (2013).
  3. Ceballos Sierra F. J., Enciclopedia del Lenguaje C++. Ra-Ma, 2ª Edición (2009).

1 comentario: