Memoria, stack, heap y punteros
Qué guarda realmente una variable y cómo apuntar a una dirección
Entender la memoria es entender qué hace C++ debajo de cada variable. Aquí verás cómo se organizan el stack y el heap, qué guarda un puntero, cómo se desreferencia y por qué un puntero mal usado es la fuente de los errores más difíciles.
- Explicar la diferencia entre stack y heap con direcciones concretas
- Declarar punteros, obtener direcciones con & y acceder con *
- Recorrer un array usando aritmética de punteros
- Reservar y liberar memoria dinámica con new y delete
- Reconocer punteros nulos, colgantes y fugas de memoria
Antes necesitas: Funciones: declaración, parámetros y retorno · Paso por valor, referencia, sobrecarga y recursión
En esta lección
- ¿Qué es la memoria de un programa?
- ¿Para qué sirve entenderla?
- Cómo funciona por dentro: stack, heap y direcciones
- Sintaxis: & y *
- Ejemplo básico: leer y modificar a través de un puntero
- Ejemplo intermedio: recorrer un array con aritmética de punteros
- Caso práctico: reservar memoria en el heap
- Visualización: mapa de memoria
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Cuando escribes int x = 42;, C++ no guarda el número «en el aire». Reserva un espacio en la memoria RAM, escribe allí los bits que representan 42 y asocia el nombre x a esa posición. Un puntero es una variable cuyo valor es precisamente eso: una dirección de memoria.
Esta lección es la bisagra del curso. Hasta ahora las variables eran cajas opacas; a partir de aquí vas a ver las direcciones, a decidir dónde vive cada dato y a entender por qué C++ puede ser tan rápido y tan peligroso al mismo tiempo.
¿Qué es la memoria de un programa?
Piensa en la memoria como una calle larguísima de casas numeradas. Cada casa guarda un byte y tiene una dirección única. Una variable de tipo int ocupa normalmente 4 casas contiguas; un double, 8. El compilador decide en qué dirección empieza cada variable y traduce x a esa dirección.
C++ organiza la memoria de un proceso en varias zonas. Las dos que más te importan ahora son el stack (pila) y el heap (montón):
| Zona | Qué guarda | Quién la gestiona | Velocidad |
|---|---|---|---|
| Stack | Variables locales, parámetros, direcciones de retorno | El compilador, automáticamente | Muy rápida, acceso directo |
| Heap | Objetos creados con new, datos de tamaño dinámico | Tú, con new y delete | Más lenta, requiere búsqueda de espacio |
| Estáticas | Globales y static, viven todo el programa | El sistema al cargar el programa | Rápida |
| Código | Las instrucciones compiladas | El sistema (solo lectura) | Se ejecuta desde ahí |
¿Para qué sirve entenderla?
- Saber cuándo algo se destruye: una variable local desaparece al salir de la función; si guardaste su dirección, esa dirección queda apuntando a basura.
- Compartir datos sin copiarlos: pasar un puntero o una referencia evita copiar estructuras grandes, clave en Machine Learning.
- Crear estructuras de tamaño variable: un
std::vectorreserva su buffer en el heap y lo administra por ti. - Depurar errores reales: los segmentation faults casi siempre son punteros nulos, colgantes o fuera de rango.
- Entender C++ moderno:
unique_ptr,shared_ptr,std::stringystd::vectorson envoltorios seguros sobre estas mismas ideas.
Cómo funciona por dentro: stack, heap y direcciones
Cada vez que llamas a una función, el programa reserva un stack frame: un bloque contiguo con los parámetros, las variables locales y la dirección a la que debe volver. Al terminar, el puntero de pila retrocede y ese bloque se considera libre. No se borra físicamente: simplemente deja de pertenecerte, y la siguiente llamada puede reutilizarlo.
El heap es distinto: no tiene un orden de entrada y salida. Pides espacio con new, el sistema busca un bloque libre y te devuelve su dirección. Ese bloque sigue existiendo hasta que lo liberas con delete, aunque la función que lo creó haya terminado. Esa independencia es su ventaja y su trampa: si olvidas liberarlo, la memoria queda ocupada hasta que el proceso muere.
Una dirección se representa en hexadecimal, por ejemplo 0x7ffc1a2b3c4d. En el stack suele ser alta y cercana; en el heap, más baja. No importa el número exacto: importa que dos punteros son iguales si apuntan a la misma dirección, no si los valores apuntados coinciden.
Sintaxis: & y *
Dos operadores hacen casi todo el trabajo. Se leen según el contexto, y esa es la primera fuente de confusión:
&x— dirección de: devuelve la dirección donde vivex.int* p— tipo puntero:pguarda la dirección de unint.*p— desreferencia: accede al valor que hay en la dirección guardada enp.p = nullptr— apunta a ninguna parte; desreferenciarlo es error en tiempo de ejecución.p -> miembro— azúcar para(*p).miembro, muy usado con structs y clases.
int x = 42;
int* p = &x; // p guarda la dirección de x
std::cout << x << '\n'; // 42
std::cout << &x << '\n'; // 0x... (dirección)
std::cout << p << '\n'; // la misma dirección
std::cout << *p << '\n'; // 42 (valor apuntado)
*p = 7; // modifica x a través de p
std::cout << x << '\n'; // 7Ejemplo básico: leer y modificar a través de un puntero
#include <iostream>
int main() {
int x = 42;
int* p = &x;
std::cout << "x = " << x << '\n';
std::cout << "*p = " << *p << '\n';
*p = 7;
std::cout << "x despues = " << x << '\n';
return 0;
}int x = 42;reserva 4 bytes en el stack y escribe 42.int* p = &x;reserva otros 8 bytes (en un sistema de 64 bits) para guardar la dirección dex.*plee el valor que hay en esa dirección: 42. No es una copia dex; es la misma celda.*p = 7;escribe 7 en esa celda, así quextambién vale 7. No hay dos variables, hay una sola con dos formas de acceder a ella.
Ejemplo intermedio: recorrer un array con aritmética de punteros
Un array es un bloque contiguo de elementos. El nombre del array, en la mayoría de expresiones, se convierte en un puntero a su primer elemento. Sumar 1 a un puntero no suma un byte: avanza un elemento completo. Esa es la regla que hace que p + 1 funcione igual para int, double o una struct de 40 bytes.
int a[3] = {10, 20, 30};int* p = a;std::cout << *p << '\n';std::cout << *(p + 2) << '\n';p++;std::cout << *p << '\n';a decae a un puntero a su primer elemento.—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
int* p = a;
(*p)++; // a[0] pasa a 11
p + 1; // calcula una dirección, pero no la guardaint* p = a;
p++; // p ahora apunta a a[1]
std::cout << *p;Los paréntesis cambian por completo el significado. `(*p)++` incrementa el valor apuntado; `p++` incrementa la dirección. Y `p + 1` sin asignación no modifica `p`: solo produce una dirección temporal.
Caso práctico: reservar memoria en el heap
new int(5) pide espacio para un entero en el heap, lo inicializa con 5 y devuelve su dirección. Esa memoria no se libera sola: debes llamar a delete cuando termines. En código moderno casi nunca escribirás esto directamente; usarás std::vector o std::unique_ptr. Pero entenderlo es obligatorio para leer código antiguo y para saber qué hay dentro de esas abstracciones.
#include <iostream>
int main() {
int* q = new int(5); // reserva en el heap
std::cout << *q << '\n'; // 5
*q = *q + 1; // 6
std::cout << *q << '\n'; // 6
delete q; // libera el bloque
q = nullptr; // evita un puntero colgante
return 0;
}Visualización: mapa de memoria
Avanza por los pasos y observa tres cosas: dónde vive cada variable, qué valor guarda y a qué dirección apunta. Fíjate en que p no contiene 42: contiene la dirección donde está 42.
vacío
x ocupa 4 bytes en el stack y guarda 42.Ver código de esta simulación
int x = 42;
int* p = &x;
std::cout << *p;
*p = 7;Cada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.
Ahora el mismo esquema con memoria dinámica. La diferencia esencial: el bloque del heap sobrevive a la función que lo creó, y delete lo devuelve al sistema.
q ──▶ 0x5500 new int(5) reserva 4 bytes en el heap, escribe 5 y devuelve la dirección 0x5500. q la guarda.Ver código de esta simulación
int* q = new int(5);
*q = *q + 1;
delete q;
q = nullptr;Cada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.
Errores comunes
- Desreferenciar un puntero nulo:
int* p = nullptr; *p = 3;provoca un fallo de segmentación inmediato. Compruebaif (p != nullptr)cuando el puntero pueda no apuntar a nada. - Usar un puntero colgante: guardas
&x, la función termina yxdesaparece. La dirección sigue ahí, pero el contenido ya no es tuyo. - Olvidar
delete: cadanewsin sudeletedeja un bloque ocupado (fuga). En un bucle, puede agotar la memoria. - Hacer
deletedos veces: liberar el mismo bloque dos veces corrompe el heap. Trasdelete, asignanullptr. - Salir del array:
*(p + 3)en un array de 3 elementos es comportamiento indefinido; puede no fallar y aun así ser un error grave. - Confundir el tamaño:
p + 1avanzasizeof(*p)bytes, no uno. Ese es el comportamiento correcto, pero explica por qué unchar*y unint*no avanzan igual.
Buenas prácticas
- Inicializa siempre:
int* p = nullptr;es más seguro que un puntero con basura. - Usa
const int* pcuando no vayas a modificar el valor apuntado; el compilador te protege. - Prefiere
std::vectoranew int[n], ystd::unique_ptranew/deletemanual. - No devuelvas punteros a variables locales de una función.
- Pasa objetos grandes como
const T&en lugar de por valor o puntero crudo. - Compila con
-Wall -Wextray ejecuta con AddressSanitizer (-fsanitize=address) cuando depures memoria. - Si una función recibe un puntero que no puede ser nulo, documenta ese contrato; si puede, compruébalo.
x = 42
p = x # p apunta al mismo objeto
x = 7 # p sigue siendo 42
print(p) # 42int x = 42;
int* p = &x; // p guarda la direccion
x = 7;
std::cout << *p; // 7En Python los nombres se reasignan a objetos y no hay aritmética de punteros visible. En C++ un puntero guarda una dirección concreta: si el valor cambia, `*p` lo refleja. Esa explicitud es la que permite controlar el rendimiento y también la que exige disciplina.
Ejercicios
Intercambiar dos números con punteros
void intercambiar(int* a, int* b) que intercambie los valores apuntados. Llámala desde main con dos variables y comprueba que ambas cambian. No uses std::swap.Pista
Guarda *a en una variable temporal antes de sobrescribirlo. Dentro de la función, a y b son punteros; los valores son *a y *b.
Sumar un array sin índices
int datos[5] = {2, 4, 6, 8, 10};, calcula la suma recorriendo el array con un puntero (int* p = datos y p++), sin usar datos[i]. Muestra el resultado.Pista
El puntero avanza hasta datos + 5. Puedes comparar p < datos + 5 o usar un contador de elementos.
int x = 10; int* p = x; std::cout << p; // debe imprimir 10 p = 20; // x pasa a 20
Para guardar la dirección se usa `&`; para leer o escribir el valor apuntado, `*`.
Soluciones explicadas
En el primer ejercicio, la clave es que a y b son copias de las direcciones, no copias de los enteros. Por eso *a accede a la variable original. En el segundo, el puntero recorre el array sin índice; el límite datos + 5 es legal porque apunta una posición después del último elemento, pero nunca debes desreferenciarlo. Esa distinción entre «dirección válida para comparar» y «dirección válida para leer» es exactamente la clase de detalle que separa un programa correcto de uno que falla solo a veces.
Resumen
- Toda variable vive en una dirección; un puntero almacena esa dirección.
&obtiene la dirección y*accede al valor apuntado.- El stack es automático y rápido; el heap es manual y persistente hasta
delete. - La aritmética de punteros avanza en unidades del tipo apuntado, no en bytes.
- Un puntero nulo, colgante o fuera de rango es la causa de los fallos más difíciles.
- Inicializa con
nullptr, usaconsty prefierestd::vector/unique_ptral manejo manual. - Entender esta capa es lo que permite entender después RAII, move semantics y el rendimiento de C++.
Evaluación
Después de int x = 5; int* p = &x; *p = 9;, ¿cuánto vale x?
¿Cuál es la diferencia principal entre stack y heap?
Si int* p apunta a a[0], ¿qué hace p + 2 en un sistema con int de 4 bytes?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.