</>waridocu

C++

Punteros: direcciones de memoria

Qué guarda un puntero, cómo se usan & y *, su relación con los arrays y los peligros clásicos que hay que conocer.

cpppunterosmemorianullptrreferencias

De dónde venimos

Arrays y cadenas de estilo C terminó con una pregunta abierta: ¿por qué una función puede modificar un array del que solo recibió el nombre, sin ningún & de por medio? La respuesta fue “porque el array se degrada a un puntero”, pero esa palabra quedó sin explicar. Esta página la explica.

Los punteros conectan tres cosas que ya viste por separado:

  • La idea de que toda variable vive en una dirección de memoria, mencionada al pasar en Qué es una variable.
  • El paso por referencia de Funciones, que resulta ser una versión más segura y limitada de lo mismo.
  • La memoria contigua de los arrays, que es lo que hace posible la aritmética de punteros.
Importante

Los punteros tienen fama de ser el tema difícil de C++, y esa fama viene sobre todo de mezclarlos con memoria dinámica (new/delete) desde el primer minuto. Aquí no vamos a hacer eso: esta página cubre únicamente qué es un puntero y cómo se usa sobre variables que ya existen. Reservar memoria manualmente y los smart pointers son tema de una sección más avanzada.

Qué es un puntero

Un puntero es una variable cuyo valor es la dirección de memoria de otro dato. Nada más que eso.

La analogía habitual funciona bien: si una variable es una casa que contiene cosas, un puntero es un papelito con la dirección escrita de esa casa. El papelito no es la casa, es mucho más pequeño, y puedes copiarlo y pasarlo por ahí sin mover la casa de sitio. Para ver lo que hay dentro, tienes que ir a la dirección anotada.

Dos operadores nuevos, y son el corazón de todo el tema:

OperadorNombreQué hace
&xDirección deDevuelve la dirección de memoria de x
*pDesreferenciaAccede al valor que está en la dirección p
Ejemplo: crear un puntero y usarlocpp
#include <iostream>

int main() {
    int edad = 30;
    int* p = &edad;        // p guarda la DIRECCIÓN de edad

    std::cout << "Valor de edad:      " << edad  << "\n";
    std::cout << "Dirección de edad:  " << p     << "\n";
    std::cout << "Valor apuntado (*p): " << *p   << "\n";

    *p = 31;               // escribir A TRAVÉS del puntero
    std::cout << "edad ahora vale:    " << edad  << "\n";
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o punteros punteros.cpp
./punteros
texttext
Valor de edad:      30
Dirección de edad:  0x7ffd4c2a1b3c
Valor apuntado (*p): 30
edad ahora vale:    31
Nota

La dirección que imprime tu ejecución será distinta a la de arriba, y distinta también entre dos ejecuciones seguidas del mismo programa: el sistema operativo coloca el programa en un lugar diferente cada vez (una protección de seguridad llamada ASLR). Si tu salida muestra otro número hexadecimal, todo está bien.

La línea clave es *p = 31;. No cambiamos p (sigue apuntando al mismo sitio), sino el contenido de la dirección a la que apunta. Y edad, que vive en esa dirección, cambió. Ese es el mecanismo entero.

El * significa dos cosas distintas

Es la confusión número uno al empezar, y conviene nombrarla de frente:

  • En una declaración, int* p; significa “p es un puntero a int”. Ahí el * es parte del tipo.
  • En una expresión, *p significa “el valor al que apunta p”. Ahí el * es el operador de desreferencia.

Lo mismo pasa con &: en int& r = x; es una referencia (lo que viste en Funciones); en &x es “dirección de”. El símbolo se reutiliza; el significado depende de si estás declarando un tipo o escribiendo una expresión.

Tip

int* p; e int *p; son idénticos para el compilador — el espacio no importa. La primera forma se lee mejor (“tipo int*, variable p”), pero cuidado con int* a, b;: solo a es puntero, b es un int normal. Por eso mucha gente declara un puntero por línea.

Punteros y referencias: en qué se diferencian

Una referencia (int&) hace un trabajo parecido al de un puntero, pero con restricciones que la hacen más segura:

Puntero (int* p)Referencia (int& r)
¿Puede no apuntar a nada?Sí (nullptr)No, siempre está ligada a algo
¿Se puede reasignar a otra variable?No, una vez ligada es para siempre
¿Necesita * para leer el valor?Sí (*p)No, se usa como la variable normal
¿Puede recorrer un array?Sí (aritmética de punteros)No

La regla práctica en C++ moderno: usa una referencia cuando siempre habrá un objeto válido, y un puntero cuando “ningún objeto” es una posibilidad legítima (por ejemplo, una búsqueda que puede no encontrar nada y devuelve nullptr).

La relación entre arrays y punteros

Ahora sí se puede cerrar el cabo suelto de la página anterior. El nombre de un array, usado en una expresión, se convierte en un puntero a su primer elemento:

Ejemplo: un array es (casi) un puntero a su primer elementocpp
#include <iostream>

int main() {
    int numeros[3] = {10, 20, 30};
    int* p = numeros;              // sin &: el array ya "es" una dirección

    std::cout << *p       << "\n"; // 10  -> primer elemento
    std::cout << *(p + 1) << "\n"; // 20  -> segundo elemento
    std::cout << p[2]     << "\n"; // 30  -> tercer elemento, sintaxis de array
    return 0;
}
texttext
10
20
30

Tres cosas que este ejemplo demuestra:

  1. int* p = numeros; no necesita & porque el nombre del array ya se degrada a dirección.
  2. p + 1 no suma 1 byte: suma el tamaño de un int (4 bytes típicamente), y por eso cae exactamente en el siguiente elemento. Eso es la aritmética de punteros, y solo tiene sentido porque los arrays son memoria contigua.
  3. p[2] funciona sobre un puntero. De hecho, la sintaxis arr[i] que usaste toda la página anterior es, para el compilador, exactamente *(arr + i).
Advertencia

Que sean casi lo mismo no significa que sean lo mismo. sizeof(numeros) dentro de main() da 12 (los tres enteros), pero sizeof(p) da 8 (el tamaño de una dirección). Esta es la explicación de fondo de la trampa de sizeof mencionada en la página de arrays: al pasar el array a una función solo viaja el puntero, y el tamaño se pierde por el camino.

El puntero nulo: nullptr

Un puntero que no apunta a ningún objeto válido debe valer nullptr. Es un valor especial que significa explícitamente “aquí no hay nada”, y se puede comprobar antes de desreferenciar.

Ejemplo: comprobar un puntero antes de usarlocpp
#include <iostream>

// Devuelve un puntero al primer número negativo, o nullptr si no hay ninguno
int* buscarNegativo(int datos[], int cantidad) {
    for (int i = 0; i < cantidad; ++i) {
        if (datos[i] < 0) {
            return &datos[i];
        }
    }
    return nullptr;               // "no encontré nada"
}

int main() {
    int valores[5] = {3, 8, -2, 5, 1};

    int* encontrado = buscarNegativo(valores, 5);

    if (encontrado != nullptr) {  // SIEMPRE comprobar antes de desreferenciar
        std::cout << "Primer negativo: " << *encontrado << "\n";
    } else {
        std::cout << "No hay negativos.\n";
    }
    return 0;
}
bashbash
g++ -std=c++17 -Wall -o buscar buscar.cpp
./buscar
texttext
Primer negativo: -2

Este ejemplo también responde a la pregunta “¿y para qué querría yo un puntero?”: para poder devolver “no encontré nada”, algo que una referencia no puede expresar.

Fíjate además en el if (encontrado != nullptr). Esa comprobación es la razón por la que el cortocircuito de && importa: el patrón if (p != nullptr && *p > 0) es seguro precisamente porque, si p es nulo, el lado derecho nunca se evalúa.

Advertencia

Desreferenciar un puntero nulo (*p cuando p == nullptr) hace que el programa se caiga con un fallo de segmentación (segmentation fault en Linux, “Access violation” en Windows). Es, con diferencia, el crash más común de C y C++. La buena noticia: falla ruidosamente y siempre, así que es de los errores fáciles de detectar.

Nota

En código antiguo verás NULL o directamente 0 en lugar de nullptr. Funcionan, pero NULL es en realidad el número cero disfrazado, y eso genera ambigüedades con las funciones sobrecargadas (f(NULL) puede elegir f(int) en vez de f(char*)). Desde C++11, usa siempre nullptr: tiene su propio tipo y no se confunde con un entero.

Punteros no inicializados y punteros colgantes

Estos dos son los peligros clásicos, y a diferencia del puntero nulo, no fallan ruidosamente: fallan a veces, o parecen funcionar hasta que dejan de hacerlo.

Puntero no inicializado. Es la versión con punteros de la advertencia sobre variables sin inicializar de Variables y tipos:

Ejemplo: puntero NO inicializado (código roto a propósito)cpp
int* p;        // contiene basura: una dirección cualquiera
*p = 42;       // escribe en una dirección al azar -> comportamiento indefinido

p no vale nullptr: vale lo que hubiera en esa zona de memoria. Escribir ahí puede corromper otra variable de tu propio programa sin que nada se queje. Solución: inicializa siempre, aunque sea con int* p = nullptr;.

Puntero colgante (dangling pointer). Apunta a memoria que ya dejó de ser válida:

Ejemplo: puntero colgante (código roto a propósito)cpp
int* crearMal() {
    int local = 42;
    return &local;      // local muere al terminar la función
}

int main() {
    int* p = crearMal();
    std::cout << *p;    // lee memoria que ya no existe: indefinido
}

Es exactamente el mismo error que la advertencia de “no devuelvas una referencia a una variable local” en Funciones, y por la misma causa: el ámbito. Cuando crearMal() termina, su variable local desaparece y la dirección queda apuntando a terreno abandonado. Lo insidioso es que el valor 42 puede seguir ahí unos microsegundos, así que el programa puede imprimir 42 y parecer correcto en tu prueba, y romperse en producción.

Tip

Estos errores son invisibles a simple vista, pero hay herramientas que los detectan en ejecución. Compila con los sanitizers mientras desarrollas:

bashbash
g++ -std=c++17 -Wall -fsanitize=address,undefined -g -o programa programa.cpp
./programa

Si hay un acceso fuera de rango, un puntero colgante o una desreferencia inválida, el programa se detiene y te dice el archivo y la línea exacta. Es la diferencia entre depurar durante horas y arreglarlo en un minuto.

Lo que deliberadamente no vimos aquí

Todos los punteros de esta página apuntan a variables que ya existían. La otra mitad del tema —pedirle memoria al sistema con new, devolverla con delete, las fugas de memoria (memory leaks) que ocurren cuando te olvidas, y los std::unique_ptr / std::shared_ptr que automatizan todo eso— pertenece a la sección avanzada. Con lo de aquí ya puedes leer y entender la mayoría del código que te encuentres; lo otro es lo que necesitarás para escribir tus propias estructuras de datos.

Siguiente paso

Ya sabes agrupar datos del mismo tipo (arrays) y referirte a datos ajenos (punteros). Falta agrupar datos de tipos distintos que describen una misma cosa: continúa con Structs.