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

Referencias, new/delete y fugas de memoria

El alias que no puede ser nulo y el precio de reservar memoria a mano

Una referencia es un alias seguro de una variable existente; new y delete te dan control total del heap y, con él, la responsabilidad de liberar. Aquí aprenderás a usar ambos sin fugas, sin dobles liberaciones y sin punteros colgantes.

Lección 2 de 3 del módulo
Al terminar esta lección podrás
  • Distinguir una referencia de un puntero y elegir cuál usar
  • Reservar y liberar un objeto con new y delete
  • Emparejar correctamente new[] con delete[]
  • Reconocer fugas, dobles liberaciones y punteros colgantes en código propio
  • Compilar con AddressSanitizer para localizar errores de memoria

Antes necesitas: Memoria, stack, heap y punteros · Paso por valor, referencia, sobrecarga y recursión

En esta lección
  1. ¿Qué es una referencia?
  2. ¿Para qué sirve reservar memoria dinámica?
  3. ¿Cómo funciona por dentro?
  4. Sintaxis
  5. Ejemplo básico: modificar a través de una referencia
  6. Ejemplo intermedio: un array dinámico y dos funciones
  7. Caso práctico: ampliar un array sin perder el original
  8. Visualización: referencia y heap paso a paso
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

En la lección anterior viste que un puntero guarda una dirección y que puede ser nulo, reasignarse o perderse. Una referencia es la otra cara de esa idea: un nombre alternativo para una variable que ya existe. No guarda una dirección que puedas cambiar, no puede estar vacía y no se puede mover a otro objeto. A cambio, es imposible que apunte a la nada.

La segunda mitad de la lección trata del otro extremo: pedir memoria al heap con new y devolverla con delete. Ese par de operadores es la puerta por la que entran las fugas de memoria, las dobles liberaciones y los punteros colgantes. No los vas a usar a diario en código moderno, pero aparecen en bibliotecas, en entrevistas y en cada error de memoria que tengas que diagnosticar.

¿Qué es una referencia?

Una referencia se declara con & después del tipo: int& r = x;. A partir de esa línea, r y x son dos nombres para la misma celda de memoria. Escribir r = 20 no copia 20 dentro de una variable nueva: escribe 20 en x. Leer r es leer x. No hay indirección visible, no hay *, no hay comprobación de nulos.

Piensa en una persona con dos nombres: en el registro civil figura como «Ana Pérez» y en casa le dicen «Anita». Son la misma persona; cambiarle la dirección a una afecta a la otra porque no son dos entidades. Con los punteros es distinto: un puntero es una nota con la dirección de una casa, y esa nota puede reescribirse para que señale otra casa.

AspectoReferencia `T&`Puntero `T*`
InicializaciónObligatoria al declararPuede quedar sin inicializar (peligroso)
Valor nuloNo existe; siempre referencia a un objetonullptr es válido y hay que comprobarlo
Reasignaciónr = b copia el valor, no cambia de objetop = &b cambia el objeto apuntado
Sintaxis de accesoDirecta: rCon * o ->
AritméticaNo aplicap + 1 avanza un elemento
Uso típicoParámetros y retornos de funcionesEstructuras enlazadas y memoria dinámica

¿Para qué sirve reservar memoria dinámica?

  • Tamaño decidido en ejecución: si el número de filas de una matriz llega por un archivo, no puedes declarar double datos[n] con n desconocido en tiempo de compilación.
  • Objetos que sobreviven al ámbito: un bloque creado con new sigue vivo cuando la función que lo creó retorna, hasta que alguien lo libere.
  • Estructuras enlazadas: listas, árboles y grafos necesitan nodos que se crean y destruyen en cualquier orden.
  • Evitar copias grandes: pasar const T& o un puntero a una matriz de un millón de elementos cuesta ocho bytes, no ocho megabytes.
  • Entender el coste real: cada new consulta al asignador, y cada delete lo devuelve. Saber esto explica por qué abusar de la memoria dinámica arruina el rendimiento.

En Machine Learning lo verás constantemente: los buffers de un std::vector o de un tensor viven en el heap, y las bibliotecas de álgebra lineal reservan y reutilizan bloques para no pagar el coste en cada operación. La diferencia es que esas bibliotecas encapsulan la liberación; cuando lo haces a mano, esa responsabilidad pasa a tu cabeza.

¿Cómo funciona por dentro?

El compilador suele implementar una referencia como un puntero constante que se desreferencia automáticamente. La diferencia es de contrato, no de hardware: el lenguaje garantiza que una referencia siempre está ligada a un objeto válido, así que no genera comprobaciones de nulo. Cuando el compilador puede ver el objeto original, ni siquiera guarda la dirección: usa la variable directamente.

new T(args) hace dos cosas: pide al asignador un bloque de sizeof(T) bytes y construye allí el objeto llamando a su constructor. delete p hace las dos inversas: llama al destructor y devuelve el bloque. Si el constructor lanza una excepción, el bloque se libera solo; un destructor que lanza puede terminar el programa. Para arrays, new T[n] reserva espacio para n objetos, construye cada uno y normalmente guarda el número de elementos en una cabecera oculta; delete[] p lee esa cabecera para saber cuántos destructores invocar.

Esa cabecera explica el error más común: delete sobre un array libera el bloque pero no conoce el número de elementos, así que solo destruye el primero. El resultado es una liberación incompleta o directamente un fallo del asignador. La regla no admite excepciones: cada new[] se empareja con delete[] y cada new con delete.

Sintaxis

CPP sintaxis.cpp
#include <iostream>

int main() {
    int x = 10;
    int& r = x;              // alias: r es x
    r = 20;
    std::cout << x << '\n';  // 20

    int* p = new int(7);     // un entero en el heap
    std::cout << *p << '\n'; // 7
    delete p;                // libera ese entero
    p = nullptr;

    int* bloque = new int[3]{1, 2, 3};
    std::cout << bloque[2] << '\n'; // 3
    delete[] bloque;         // obligatorio: fue new[]
    bloque = nullptr;

    return 0;
}
  • int& r = x; liga r a x para siempre. int& r; no compila: una referencia debe nacer apuntando a algo.
  • new int(7) reserva e inicializa con 7. new int[3]{1, 2, 3} reserva tres enteros y los inicializa con una lista.
  • delete p; libera un objeto; delete[] bloque; libera un array. Después conviene asignar nullptr para que un segundo delete sea inofensivo.
  • delete nullptr; es legal y no hace nada. Por eso poner a nullptr tras liberar evita la doble liberación accidental.

Ejemplo básico: modificar a través de una referencia

CPP basico.cpp
#include <iostream>

void duplicar(int& valor) {
    valor *= 2;          // modifica la variable original
}

int main() {
    int a = 5;
    duplicar(a);
    std::cout << a << '\n'; // 10

    int& alias = a;      // otro nombre para a
    alias += 1;
    std::cout << a << '\n'; // 11

    return 0;
}

La función recibe int&, así que no trabaja con una copia: valor es la misma celda que a. Por eso a termina en 10 sin que la función devuelva nada. En main, alias tampoco es una copia; alias += 1 escribe directamente en a, que pasa a 11. Observa que en la llamada escribes duplicar(a), sin &: el compilador sabe que el parámetro es una referencia.

Ejemplo intermedio: un array dinámico y dos funciones

CPP intermedio.cpp
#include <iostream>

void escalar(double* datos, int n, double factor) {
    for (int i = 0; i < n; ++i) {
        datos[i] *= factor;
    }
}

double suma(const double* datos, int n) {
    double total = 0.0;
    for (int i = 0; i < n; ++i) {
        total += datos[i];
    }
    return total;
}

int main() {
    const int n = 4;
    double* datos = new double[n]{1.0, 2.0, 3.0, 4.0};

    escalar(datos, n, 0.5);
    std::cout << suma(datos, n) << '\n'; // 5

    delete[] datos;   // new[] exige delete[]
    datos = nullptr;
    return 0;
}

escalar recibe un puntero porque necesita modificar el contenido; suma recibe const double* porque solo lee, y ese const impide que se te escape una escritura por accidente. El array se reserva una vez, se pasa a dos funciones y se libera una sola vez en main. Ese patrón —quien reserva es quien libera— es el más fácil de auditar cuando todavía no usas RAII.

Caso práctico: ampliar un array sin perder el original

Los arrays de C++ tienen tamaño fijo. Para «crecer» uno hay que reservar un bloque nuevo, copiar los elementos, liberar el viejo y quedarse con el nuevo puntero. El orden importa: si liberas antes de copiar, lees memoria liberada; si olvidas liberar el viejo, tienes una fuga.

CPP ampliar.cpp
#include <iostream>

int* ampliar(const int* origen, int n, int nuevo_tam) {
    int* nuevo = new int[nuevo_tam]{};   // valor-inicializado a 0
    for (int i = 0; i < n; ++i) {
        nuevo[i] = origen[i];
    }
    return nuevo;
}

int main() {
    int* v = new int[3]{10, 20, 30};
    int* w = ampliar(v, 3, 5);

    delete[] v;      // el bloque antiguo ya no se usa
    v = w;

    std::cout << v[0] << ' ' << v[2] << ' ' << v[4] << '\n'; // 10 30 0

    delete[] v;
    v = nullptr;
    return 0;
}

new int[nuevo_tam]{} con llaves vacías inicializa a cero, así que las dos posiciones nuevas valen 0. La copia ocurre antes de liberar v; después w pasa a ser el dueño y v se reasigna. Si ampliar fallara a mitad de camino, el bloque nuevo se perdería: ese es exactamente el tipo de problema que std::vector resuelve por ti y que verás en la próxima lección.

Fuga por salida anticipada
Incorrecto
PYTHON Incorrecto: reserva y luego valida
int* crear(int n) {
    int* v = new int[n];
    if (n <= 0) return nullptr;  // el bloque se pierde
    return v;
}
Correcto
PYTHON Correcto: valida antes de reservar
int* crear(int n) {
    if (n <= 0) return nullptr;
    return new int[n];
}

Cuando la validación va después de `new`, el `return nullptr` abandona la función sin que nadie guarde el puntero al bloque: la memoria queda ocupada hasta que el proceso termina. Valida primero o envuelve el puntero en un objeto que libere al salir.

Visualización: referencia y heap paso a paso

Avanza por los pasos y compara dos comportamientos. r no crea una celda nueva: es otro nombre para x, así que escribir en r cambia x. En cambio p sí ocupa ocho bytes en el stack y guarda la dirección de un bloque del heap, que deja de existir cuando llamas a delete.

Mapa de memoria ·Alias en el stack y un bloque en el heap
1/6
STACK memoria automática
x 0x7ffc1000
10
int
↕
HEAP memoria dinámica

vacío

x reserva 4 bytes en el stack y guarda 10. Todavía no hay nada en el heap.
última acción: `x` reserva 4 bytes en el stack y guarda 10. Todavía no hay nada en el heap.
Ver código de esta simulación
int x = 10;
int& r = x;
r = 20;
int* p = new int(7);
delete p;
p = nullptr;

Cada paso muestra qué vive en el stack, qué se reserva en el heap y cómo apunta cada variable.

Errores comunes

  • Fuga de memoria: cada new sin delete deja un bloque inaccesible. No falla al momento; el proceso crece hasta que el sistema lo mata. free(): double free detected no aparece, simplemente la memoria se agota.
  • Doble liberación: delete p; delete p; corrompe el heap y suele abortar con free(): double free detected in tcache 2. Asignar p = nullptr entre ambas lo evita porque delete nullptr no hace nada.
  • Mezclar delete y delete[]: liberar con delete un bloque creado con new[] produce mensajes como munmap_chunk(): invalid pointer o un fallo silencioso. La regla es emparejar siempre.
  • Puntero colgante: guardas int* p = new int(5); delete p; y luego lees *p. La dirección sigue escrita en p, pero el bloque ya no es tuyo. Puede devolver basura o provocar Segmentation fault.
  • Devolver una referencia a una local: int& f() { int x = 3; return x; } devuelve un alias a memoria que desaparece al salir. El compilador avisa con -Wreturn-local-addr; hazle caso.
  • Referencia sin inicializar: int& r; no compila. Si necesitas «ninguna referencia», el tipo correcto es un puntero que pueda ser nullptr.

Buenas prácticas

  • Prefiere referencias para parámetros que no pueden faltar y punteros solo cuando «ninguno» sea un valor válido.
  • Marca const T& en parámetros de solo lectura: evita copias y documenta la intención.
  • Escribe delete inmediatamente después del último uso y asigna nullptr en la línea siguiente.
  • Empareja new[] con delete[] y no mezcles nunca las dos formas.
  • Reserva y libera en la misma función siempre que puedas; si no, documenta quién es el dueño del bloque.
  • No uses new para arrays de tamaño variable: std::vector hace lo mismo, crece solo y libera en su destructor.
  • Compila con g++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined mientras aprendes. El coste en velocidad no importa; el diagnóstico, sí.
SHELL detectar fugas y accesos inválidos
g++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefined fugas.cpp -o fugas
./fugas

# Salida típica si hay una fuga:
# ==12345==ERROR: LeakSanitizer: detected memory leaks
# Direct leak of 12 byte(s) in 1 object(s) allocated from:
#     #0 0x... in operator new[](unsigned long)
#     #1 0x... in main fugas.cpp:6
Liberar memoria: automático frente a manual
Python
PYTHON lista.py
datos = [0] * 3
datos.append(4)
# no hay delete: el recolector libera
# cuando nadie referencia la lista
C++
CPP array.cpp
int* datos = new int[3]{};
// ... no crece solo ...
delete[] datos;
datos = nullptr;

Python cuenta referencias y libera cuando el objeto queda inalcanzable. C++ no sabe si un bloque sigue en uso salvo que se lo digas con `delete` o lo envuelvas en un tipo con destructor (RAII). Esa diferencia explica por qué en C++ la propiedad de un puntero debe estar siempre clara.

Ejercicios

Ejercicio 1

Intercambiar con referencias

Escribe void intercambiar(int& a, int& b) que intercambie dos enteros. Llámala desde main con x = 3 e y = 8 y muestra el resultado. Compara la llamada con la versión de punteros de la lección anterior: aquí no hace falta escribir & en los argumentos.
Pista

Dentro de la función, a y b son los propios enteros, no direcciones. Usa una variable temporal para no perder el primer valor.

Ejercicio 2

Media de un array dinámico

Reserva con new double[n] un array de 5 valores {2.0, 4.0, 6.0, 8.0, 10.0}, calcula su media con una función que reciba const double* y el tamaño, imprime el resultado y libera el bloque con delete[]. Al final, deja el puntero en nullptr.
Pista

La media es la suma dividida entre n. Usa double para la división; si divides dos enteros obtendrás 6 en lugar de 6.0.

Completar código Completa la reserva y la liberación
int* p =  int(5);
std::cout << *p << '\n';
 p;
p = ;

Reservar un entero usa `new`; liberarlo, `delete`. Después se deja el puntero sin objetivo.

Soluciones explicadas

En el primer ejercicio, la clave es que la referencia no copia: intercambiar(x, y) liga los parámetros a las variables originales y las modificaciones se ven en main. Si hubieras declarado int a, int b por valor, el intercambio habría ocurrido en copias y x seguiría siendo 3. En el segundo, el detalle importante no es la media sino la propiedad: el array se reserva una vez, se pasa como const a quien solo lee y se libera exactamente una vez con la forma correcta. Ese trío —reservar, usar sin copiar, liberar— es el esqueleto de toda gestión manual de memoria.

Resumen

  • Una referencia es un alias obligatorio, no nulo y no reasignable; un puntero es una dirección que puede cambiar y puede ser nula.
  • r = b sobre una referencia copia el valor; no cambia a qué objeto se refiere.
  • new reserva y construye; delete destruye y libera. new[] y delete[] son un par inseparable.
  • Tras delete, el puntero sigue guardando la dirección: es colgante hasta que lo pones a nullptr.
  • Cada new sin su delete es una fuga; cada delete repetido, una corrupción del heap.
  • Las herramientas de saneado (-fsanitize=address) convierten errores silenciosos en informes con línea exacta.
  • El siguiente paso es dejar que el compilador libere por ti: RAII y punteros inteligentes.

Evaluación

Evaluación 1

Tras int a = 1; int b = 2; int& r = a; r = b;, ¿cuánto valen a y b?

Evaluación 2

¿Qué ocurre con int* p = new int[10]; delete p;?

Evaluación 3

¿Cuál de estas situaciones describe un puntero colgante?

Tus notas

¿Ya lo entendiste?

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