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.
- 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
- ¿Qué es una referencia?
- ¿Para qué sirve reservar memoria dinámica?
- ¿Cómo funciona por dentro?
- Sintaxis
- Ejemplo básico: modificar a través de una referencia
- Ejemplo intermedio: un array dinámico y dos funciones
- Caso práctico: ampliar un array sin perder el original
- Visualización: referencia y heap paso a paso
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- 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.
| Aspecto | Referencia `T&` | Puntero `T*` |
|---|---|---|
| Inicialización | Obligatoria al declarar | Puede quedar sin inicializar (peligroso) |
| Valor nulo | No existe; siempre referencia a un objeto | nullptr es válido y hay que comprobarlo |
| Reasignación | r = b copia el valor, no cambia de objeto | p = &b cambia el objeto apuntado |
| Sintaxis de acceso | Directa: r | Con * o -> |
| Aritmética | No aplica | p + 1 avanza un elemento |
| Uso típico | Parámetros y retornos de funciones | Estructuras 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]conndesconocido en tiempo de compilación. - Objetos que sobreviven al ámbito: un bloque creado con
newsigue 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
newconsulta al asignador, y cadadeletelo 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
#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;ligaraxpara 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 asignarnullptrpara que un segundodeletesea inofensivo.delete nullptr;es legal y no hace nada. Por eso poner anullptrtras liberar evita la doble liberación accidental.
Ejemplo básico: modificar a través de una referencia
#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
#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.
#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.
int* crear(int n) {
int* v = new int[n];
if (n <= 0) return nullptr; // el bloque se pierde
return v;
}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.
vacío
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
newsindeletedeja un bloque inaccesible. No falla al momento; el proceso crece hasta que el sistema lo mata.free(): double free detectedno aparece, simplemente la memoria se agota. - Doble liberación:
delete p; delete p;corrompe el heap y suele abortar confree(): double free detected in tcache 2. Asignarp = nullptrentre ambas lo evita porquedelete nullptrno hace nada. - Mezclar
deleteydelete[]: liberar condeleteun bloque creado connew[]produce mensajes comomunmap_chunk(): invalid pointero 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 enp, pero el bloque ya no es tuyo. Puede devolver basura o provocarSegmentation 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 sernullptr.
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
deleteinmediatamente después del último uso y asignanullptren la línea siguiente. - Empareja
new[]condelete[]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
newpara arrays de tamaño variable:std::vectorhace lo mismo, crece solo y libera en su destructor. - Compila con
g++ -std=c++20 -Wall -Wextra -g -fsanitize=address,undefinedmientras aprendes. El coste en velocidad no importa; el diagnóstico, sí.
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:6datos = [0] * 3
datos.append(4)
# no hay delete: el recolector libera
# cuando nadie referencia la listaint* 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
Intercambiar con referencias
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.
Media de un array dinámico
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.
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 = bsobre una referencia copia el valor; no cambia a qué objeto se refiere.newreserva y construye;deletedestruye y libera.new[]ydelete[]son un par inseparable.- Tras
delete, el puntero sigue guardando la dirección: es colgante hasta que lo pones anullptr. - Cada
newsin sudeletees una fuga; cadadeleterepetido, 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
Tras int a = 1; int b = 2; int& r = a; r = b;, ¿cuánto valen a y b?
¿Qué ocurre con int* p = new int[10]; delete p;?
¿Cuál de estas situaciones describe un puntero colgante?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.