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

Sobre este artículo
     Tiempo estimado de lectura: 10 minutos
     Nivel: Básico-intermedio
     Última actualización: 15 de mayo, 2026

1. Definiciones básicas

Imagen de bienvenida que presenta un asterisco sobre un fondo de color, representando el característico operador de desreferencia.
Imagen generada con inteligencia artificial
Al trabajar con el lenguaje C++, resulta inevitable el empleo de punteros —u otros tipos de objetos que emulan su comportamiento—, ya sea para alojar objetos en el free store, emplear polimorfismo dinámico, operar con contenedores y sus iteradores o introducir semántica de referencia en nuestro código. Familiarizarse con ellos, 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 a objeto es una variable que almacena la dirección en memoria de otro objeto. Salvo raras excepciones, el tamaño en memoria de un puntero a objeto es constante e independiente del tipo de dato al que apunta, y suele corresponder al modelo de direccionamiento de la plataforma: 4 bytes en sistemas de 32 bits (x86, ARM32) y 8 bytes en sistemas de 64 bits (x86-64, ARM64).
Nota 1: modelos de datos
De forma más precisa, el tamaño de un puntero viene definido por el modelo de datos de la implementación, mientras que el hardware y el sistema operativo imponen los límites efectivos del direccionamiento. Por ejemplo, en arquitecturas de 64 bits, los modelos LP64 (común en Unix/Linux) y LLP64 (MS Windows) emplean punteros de 8 bytes de tamaño.
Existen excepciones como la ABI x32, que utiliza punteros de 4 bytes (modelo ILP32) sobre hardware de 64 bits de Intel y AMD. Aunque ello limita el espacio de memoria virtual a 4 GiB, también disminuye el consumo de memoria al reducir el tamaño de los punteros, permitiendo que el programa se ejecute más rápido al dar cabida a más código y más datos en la memoria caché [1].
Nota 2: Tipos de punteros en c++
Además de los mencionados punteros a objetos, el lenguaje proporciona punteros a funciones y punteros a miembros de clase (tanto a datos como a funciones miembro). No abordaremos estos mecanismos en este artículo.
La sintaxis básica para definir un puntero (en el ejemplo, apuntando a un entero no-constante) es la siguiente:

   auto n = int{1}; // 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 (puede encontrarse más información en este artículo):

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

Representación de un puntero int*p como una flecha que señala a un entero int n en memoria. Aquí, p es un puntero de tipo int* (es decir, un puntero a entero) que apunta al entero n, almacenando su dirección en memoria (véase el esquema de la figura izquierda). Observemos, en particular, el empleo del operador unario dirección-de (&) para obtener la dirección de n e inicializar con ella a p.

La siguiente operación

   std::println("{}", static_cast<void const*>(p));  // imprime el valor del puntero

imprime en la salida estándar el valor del puntero, que en la mayoría de sistemas modernos con MMU corresponde a la dirección virtual del objeto n representada en formato hexadecimal.

Para interactuar con el objeto apuntado por el puntero, se utiliza el operador unario de indirección o desreferencia (*). Siendo p un puntero a objeto, *p es una expresión lvalue que designa al objeto apuntado por p. Esto significa, a efectos prácticos, que *p permite acceder a dicho objeto, posibilitando (con carácter general) operaciones de lectura y escritura sobre él. Así, la operación

   std::println("n = {}", *p);  // puntero desreferenciado; imprime: n = 1

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

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

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

   std::println("n = {}", n); // imprime: n = 2

2. Calificador const y semántica de constancia

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{1}; // cn es un entero constante    int const* p = &cn; // OK: 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 // 'int const*' to 'int*'
Podríamos servirnos nuevamente de la inferencia automática de tipos para obtener automáticamente la declaración correcta, si bien a riesgo de dificultar la comprensión del código:

   auto p = &cn; // OK, p es de tipo deducido 'int const*'
En este caso, 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 un objeto 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{1};  // n es un entero no-constante    int const* q = &n; // podemos leer el valor de n a través de q, // pero no modificarlo

o, equivalentemente (el asterisco es obligado en este caso, al no ser n originalmente constante):

   auto const* q = &n; // q es de tipo deducido 'int const*'

Así, el valor de n no puede ser modificado a través del puntero q, que actúa como una interfaz de sólo lectura hacia el objeto. A modo de ejemplo:

   auto m = *q + 1; // OK: operación de sólo-lectura con q; m = 2 *q = 2; // error: operación de sobreescritura de n a través de q

Tengamos presente que el puntero q garantiza la inmutabilidad del entero apuntado, pero no de sí mismo: podríamos reasignar q para que apuntase a otra dirección válida. En este sentido, no debemos confundir el caso anterior con

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

o equivalentemente (sin asterisco):

   auto const q = &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:

   auto m = *q + 1; // OK: operación de sólo-lectura con q; m = 2 *q = 2; // OK: reescribimos n a través de q; ahora, n = 2

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.

A modo de resumen:

Sintaxis
(asumiendo T como tipo no-constante)
¿Permite modificar p? ¿Permite modificar el objeto apuntado de tipo T vía p?
T* p
T const*  p No
T* const p No
T const* const p No No
NOTA 3: NOTACIÓN EAST-CONST
En la literatura y en muchos tutoriales, es muy común encontrar la sintaxis const int* en lugar de int const*. Si bien ambas son semánticamente idénticas para el compilador, en este blog optamos por emplear la segunda —conocida como east-const— de forma exclusiva.
Las ventajas de este estilo para el programador son significativas:
  • Permite leer todas las declaraciones de derecha a izquierda de forma consistente.
  • Cuando se trabaja con punteros dobles o triples (por ejemplo, int const* const* const), el estilo east-const es el único que mantiene una lógica clara y predecible.

3. Indirección y acceso a miembros

Consideremos un puntero que apunte a un objeto de una 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 -> double {  return (grade_1 + grade_2 + grade_3)/3.0;  } }; // ... auto s = Student{"James Kerry", 6.0, 8.0, 9.5}; std::println("average grade = {:.1f}", s.average()); // imprime: average grade = 7.8 Student* p = &s;

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

   (*p).grade_2 = 8.5; std::println("average grade = {:.1f}", (*p).average());  // imprime: average grade = 8.0
NOTA 4: PRECEDENCIA DE OPERADORES
El operador punto (.) tiene una precedencia mayor que el operador de desreferencia (*). Sin los paréntesis en la operación (*p).grade_2, el compilador interpretaría que estamos intentando desreferenciar *(p.grade_2). Esto conduciría a un error de compilación, puesto que p es un puntero y el operador punto no puede aplicarse a él (p carece de miembros).
La sintaxis anterior puede simplificarse, sin embargo, mediante el empleo del operador de acceso a miembros a través de puntero (conocido habitualmente como operador flecha, ->) en la forma siguiente (compárese con el código anterior):

   p->grade_2 = 8.5; std::println("average grade = {:.1f}", p->average()); // imprime: average grade = 8.0

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

Así pues, y con carácter general, dado un puntero crudo T* p a un objeto de una clase T, las expresiones (*p).member y p->member (donde member representa cualquier dato o función miembro accesible) son siempre equivalentes. Si p fuese un objeto de una clase (como un puntero inteligente) que sobrecargase los operadores operator* y operator->, dicha equivalencia descansaría en que ambos operadores implementasen una semántica coherente entre sí (habitualmente, definiendo un operador en términos del otro).
NOTA 5: SOBRECARGA DE operator* Y operator->
La sobrecarga de los operadores de desreferencia (operator*) y de acceso a miembros (operator->) es, precisamente, la base del funcionamiento de los iteradores y los punteros inteligentes (smart pointers). Estas construcciones suelen encapsular un puntero crudo y sobrecargar dichos operadores para emular la semántica de los punteros tradicionales, pero añadiendo lógica adicional (como la gestión automática de recursos o el desplazamiento a través de un contenedor) sin renunciar a su sintaxis familiar.

4. Puntero nulo

La desreferencia de un puntero sin inicializar o que no referencie a un objeto válido da lugar a comportamiento indefinido (UB, undefined behavior). Para representar de forma segura la ausencia de un objeto apuntado, C++ proporciona la palabra clave nullptr (puntero nulo), un prvalue de tipo std::nullptr_t:

int* p = nullptr; // inicialización segura

Se recomienda el uso de nullptr frente al literal 0 (incluida la macro NULL heredada del lenguaje C), muy habitual en código previo al estándar C++11. Al contrario que éstos, nullprt no es de tipo entero, lo que evita ambigüedades en la sobrecarga de funciones y garantiza que sólo pueda asignarse a tipos de punteros.
NOTA 6: DESREFERENCIA DE PUNTEROS NULOS
Es fundamental recalcar que la desreferencia de un puntero nulo (es decir, *p cuando p == nullptr) conduce a comportamiento indefinido. En sistemas con protección de memoria, esto suele manifestarse como un fallo de segmentación (segmentation fault) y la terminación abrupta del programa. Sin embargo, el estándar no garantiza este resultado. En otros entornos, el programa podría continuar ejecutándose con datos corruptos o comportamientos erráticos difíciles de depurar. Es más, los optimizadores del compilador pueden asumir que el UB no ocurre nunca, eliminando silenciosamente comprobaciones o ramas de código que el programador considere válidas.
Por ello, antes de acceder al objeto apuntado, resulta esencial verificar la validez del puntero si existe la posibilidad de que sea nulo. Gracias a la conversión implícita de los punteros al tipo bool, esta comprobación puede escribirse de forma concisa:
if (p) { // OK: p != nullptr p->do_something(); }
El puntero nulo se emplea habitualmente en la interfaz de funciones como indicación de la ausencia de un valor, como centinela en estructuras de datos dinámicas (señalando, por ejemplo, la posición final de una lista enlazada) o para indicar el fallo en una operación de búsqueda, entre otros posibles usos.

5. Punteros empleados como parámetros en funciones

Los punteros pueden emplearse para implementar el paso por referencia de argumentos a funciones. Su uso está justificado siempre que la ausencia de argumento sea una opción válida a tomar en consideración. 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 [2]. 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:

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

6. Aritmética de punteros

En una serie de artículos anterior dedicada a la gestión de memoria, explicamos cómo una expresión new aloja un objeto en el free store y devuelve un puntero que lo referencia:

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 delete.
Este ejemplo tiene una intención puramente ilustrativa, pues nunca deberíamos utilizar punteros crudos como agentes propietarios de recursos de forma no-encapsulada. En su lugar, deberíamos apoyarnos en la técnica RAII a través del empleo de contenedores estándar (como std::vector) y punteros inteligentes (como std::unique_ptr o std::shared_ptr). Estas abstracciones vinculan el ciclo de vida del recurso al ámbito (scope) del objeto que lo gestiona, garantizando su liberación automática durante el desenredo de la pila (stack unwinding), incluso si se produce una excepción. Véase la Sección 7 de este artículo para más detalles.
Como sabemos, es también posible crear un array de objetos de tipo Student en el free store mediante una expresión new[], cuyos elementos se almacenan en memoria contigua:

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

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

Puntero p apuntando a un array de 100 objetos Student, con índices de acceso de 0 a 99.
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 asignaciones:

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

equivalente a

   (p + 4)->grade_1 = 10.0;

Conviene enfatizar que p + i no incrementa la dirección en i bytes, sino en i*sizeof(Student) bytes. Es decir, el compilador escala automáticamente el desplazamiento en función del tamaño del tipo apuntado.

Atendiendo al hecho de que la dirección almacenada en el puntero p coincide con la dirección de inicio del array, es también posible utilizar la sintaxis de indexación habitual en C y C++ y escribir

   p[4].grade_1 = 10.0;

siendo p[i] semánticamente equivalente a *(p + i). Esta última sintaxis es la más conveniente por razones evidentes. Conviene entonces visualizar al puntero p como una flecha que apunta al primer elemento del array (entrada de índice 0), al puntero (p + 1) como una flecha que señala al elemento de índice 1, y así sucesivamente (véase la figura superior). Los elementos del array se corresponden entonces con 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 pertenecientes a un mismo array devuelve el número de elementos entre los objetos por ellos apuntados (de hecho, un puntero tradicional a array constituye el ejemplo más simple de iterador de acceso aleatorio, satisfaciendo el concepto std::contiguous_iterator a partir de C++20 [3]):

  // q (de tipo Student*) apunta al quinto estudiante, p[4]:  auto q = p + 4; std::println("distance = {}", q - p); // imprime: distance = 4 

La diferencia resultante es de tipo std::ptrdiff_t [4].

7. Consideraciones finales

En la mayoría de los casos, el uso de punteros crudos a objetos puede reservarse para:
  • La implementación interna de estructuras de datos, iteradores y algoritmos.
  • Escenarios de observación no-propietaria donde el valor nullptr sea un estado válido.
  • Interoperabilidad con APIs C o del sistema.
Es importante ser conscientes de que, para el diseño de software seguro y eficiente, el lenguaje proporciona alternativas que resultan semánticamente superiores. Entre ellas, citaríamos:
  1. Contenedores y punteros inteligentes para la gestión de memoria fundamentada en la técnica RAII, garantizando que el ciclo de vida de los objetos sea gestionado de forma automática y segura mediante las reglas de ámbito y destrucción determinista del lenguaje. Deben evitarse expresiones new y delete no-encapsuladas para evitar riesgos de fugas de memoria y punteros colgantes.
  2. Las abstracciones de iteradores y rangos para el acceso y la manipulación de colecciones de datos. Ambas se erigen como herramientas superiores frente a la aritmética de punteros tradicional. Si bien muchos iteradores de acceso aleatorio son implementados como meros punteros crudos, esto constituye un detalle de implementación que no debe alterar la lógica del programador.
  3. Vistas no-propietarias como std::string_view y std::span sustituyen eficazmente a las parejas puntero-tamaño tradicionales en C, aportando mayor seguridad.
  4. Los valores opcionales std::optional (y std::optional<T&> a partir de C++26) modelan la posible ausencia de valores de forma explícita dentro del sistema de tipos del lenguaje.
Nota 7: PROCEDENCIA (PROVENANCE)
Los compiladores actuales no suelen modelar los punteros sólo como direcciones numéricas, sino que les asocian información sobre su procedencia (pointer provenance). Esta información permite realizar optimizaciones avanzadas relacionadas con aliasing, pudiendo asumir que punteros con orígenes diferentes no pueden referirse al mismo objeto, aun cuando sean idénticos bit a bit. A modo de ejemplo:
auto y = int{1}; auto x = int{2}; int* p = &x; // procedencia: objeto 'x' int* q = &y; // procedencia: objeto 'y' std::println("{}, {}", static_cast<void const*>(p + 1), static_cast<void const*>(q) ); std::println("{}", (p + 1) == q); // puede imprimir 'false' aun con direcciones idénticas

Aunque x e y puedan almacenarse en posiciones adyacentes en memoria y p + 1 y q puedan representar el mismo valor de dirección, el compilador puede asumir que el puntero p + 1 no puede designar válidamente el objeto y, optimizando la comparación directamente a false [5].

Referencias bibliográficas

  1. Wikipedia – x32 ABI – https://en.wikipedia.org/wiki/X32_ABI
  2. C++ Core Guidelines – https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#rstr-zstring
  3. cppreference – std::contiguous_iterator – https://en.cppreference.com/cpp/iterator/contiguous_iterator
  4. cppreference – std::ptrdiff_t – https://en.cppreference.com/cpp/types/ptrdiff_t
  5. n2263 – Clarifying Pointer Provenance – https://www.open-std.org/jtc1/sc22/wg14/www/docs/n2263.htm
  6. Stroustrup B., "The C++ Programming Language". Addison-Wesley, 4th Edition (2013).
  7. Ceballos Sierra F. J., "Enciclopedia del Lenguaje C++". Ra-Ma, 2ª Edición (2009).

Comentarios

Publicar un comentario