AulaML Python · C++
C++ intermedio · 52 min · Memoria y punteros

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.

Lección 1 de 3 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es la memoria de un programa?
  2. ¿Para qué sirve entenderla?
  3. Cómo funciona por dentro: stack, heap y direcciones
  4. Sintaxis: & y *
  5. Ejemplo básico: leer y modificar a través de un puntero
  6. Ejemplo intermedio: recorrer un array con aritmética de punteros
  7. Caso práctico: reservar memoria en el heap
  8. Visualización: mapa de memoria
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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):

ZonaQué guardaQuién la gestionaVelocidad
StackVariables locales, parámetros, direcciones de retornoEl compilador, automáticamenteMuy rápida, acceso directo
HeapObjetos creados con new, datos de tamaño dinámicoTú, con new y deleteMás lenta, requiere búsqueda de espacio
EstáticasGlobales y static, viven todo el programaEl sistema al cargar el programaRápida
CódigoLas instrucciones compiladasEl 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::vector reserva 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::string y std::vector son 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 vive x.
  • int* p — tipo puntero: p guarda la dirección de un int.
  • *p — desreferencia: accede al valor que hay en la dirección guardada en p.
  • 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.
CPP sintaxis.cpp
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';    // 7

Ejemplo básico: leer y modificar a través de un puntero

CPP basico.cpp
#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;
}
  1. int x = 42; reserva 4 bytes en el stack y escribe 42.
  2. int* p = &x; reserva otros 8 bytes (en un sistema de 64 bits) para guardar la dirección de x.
  3. *p lee el valor que hay en esa dirección: 42. No es una copia de x; es la misma celda.
  4. *p = 7; escribe 7 en esa celda, así que x tambié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.

Ejecución visual ·Aritmética de punteros sobre un array
1 / 6
1 int a[3] = {10, 20, 30};
2 int* p = a;
3
4 std::cout << *p << '\n';
5 std::cout << *(p + 2) << '\n';
6 p++;
7 std::cout << *p << '\n';
El array ocupa 3 ints contiguos en el stack. a decae a un puntero a su primer elemento.
Variables
a = {10, 20, 30}
p = —
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

Sumar al valor no es sumar al puntero
Incorrecto
PYTHON Incorrecto: mueve el entero, no el puntero
int* p = a;
(*p)++;   // a[0] pasa a 11
p + 1;    // calcula una dirección, pero no la guarda
Correcto
PYTHON Correcto: avanza el puntero
int* 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.

CPP heap.cpp
#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.

Mapa de memoria ·Un puntero en el stack apuntando a otra variable del stack
1/4
STACK memoria automática
x 0x7ffc1000
42
int
↕
HEAP memoria dinámica

vacío

x ocupa 4 bytes en el stack y guarda 42.
última acción: `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.

Mapa de memoria ·new, modificación y delete
1/4
STACK memoria automática
q 0x7ffc2000
0x5500
int*
↕
HEAP memoria dinámica
*q 0x5500
5
int
q ──▶ 0x5500
new int(5) reserva 4 bytes en el heap, escribe 5 y devuelve la dirección 0x5500. q la guarda.
última acción: `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. Comprueba if (p != nullptr) cuando el puntero pueda no apuntar a nada.
  • Usar un puntero colgante: guardas &x, la función termina y x desaparece. La dirección sigue ahí, pero el contenido ya no es tuyo.
  • Olvidar delete: cada new sin su delete deja un bloque ocupado (fuga). En un bucle, puede agotar la memoria.
  • Hacer delete dos veces: liberar el mismo bloque dos veces corrompe el heap. Tras delete, asigna nullptr.
  • 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 + 1 avanza sizeof(*p) bytes, no uno. Ese es el comportamiento correcto, pero explica por qué un char* y un int* no avanzan igual.

Buenas prácticas

  • Inicializa siempre: int* p = nullptr; es más seguro que un puntero con basura.
  • Usa const int* p cuando no vayas a modificar el valor apuntado; el compilador te protege.
  • Prefiere std::vector a new int[n], y std::unique_ptr a new/delete manual.
  • 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 -Wextra y 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.
Nombres en Python, direcciones en C++
Python
PYTHON referencia.py
x = 42
p = x      # p apunta al mismo objeto
x = 7      # p sigue siendo 42
print(p)   # 42
C++
CPP puntero.cpp
int x = 42;
int* p = &x;  // p guarda la direccion
x = 7;
std::cout << *p; // 7

En 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

Ejercicio 1

Intercambiar dos números con punteros

Escribe una función 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.

Ejercicio 2

Sumar un array sin índices

Dado 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.

Completar código Completa el acceso al valor
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, usa const y prefiere std::vector/unique_ptr al manejo manual.
  • Entender esta capa es lo que permite entender después RAII, move semantics y el rendimiento de C++.

Evaluación

Evaluación 1

Después de int x = 5; int* p = &x; *p = 9;, ¿cuánto vale x?

Evaluación 2

¿Cuál es la diferencia principal entre stack y heap?

Evaluación 3

Si int* p apunta a a[0], ¿qué hace p + 2 en un sistema con int de 4 bytes?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.