Lección 6 de 8: la memoria en C++. Verás dónde viven los datos (la pila y el montón), la diferencia entre referencias y punteros, por qué en C++ moderno casi nunca se escribe new ni delete, y cómo los punteros inteligentes unique_ptr, shared_ptr y weak_ptr dicen quién es el dueño de cada cosa. Terminarás con la semántica de movimiento y con las herramientas que encuentran las fugas y los usos tras liberar: AddressSanitizer y Valgrind.
La pila y el montón
Un programa en C++ guarda sus datos en dos zonas de memoria. La pila (stack) es donde viven las variables locales: cada vez que se llama a una función se reserva un hueco para las suyas, y al volver se libera solo, sin que hagas nada. Es rapidísima, pero pequeña (unos 8 MB por hilo en Linux) y lo que hay en ella muere al terminar la función. El montón (heap) es la memoria grande del proceso: ahí se pide espacio en tiempo de ejecución, del tamaño que haga falta, y dura hasta que alguien lo devuelve.
La buena noticia de C++ moderno es que casi nunca tienes que tocar el montón a mano. Un std::vector, un std::string o un std::map viven en la pila como un objeto pequeño, guardan sus datos en el montón y los devuelven en su destructor. Es la idea de RAII que viste en la lección 5, y es la base de todo lo que sigue.
v es pequeño y vive en la pila; sus datos, en el montón, y los libera su destructor. Esquema: decodigo.com.
#include <print>
#include <string>
#include <vector>
int main()
{
int x = 7; // en la pila
std::vector<int> v{1, 2, 3}; // el objeto, en la pila; los datos, en el montón
std::string corto = "hola"; // cabe dentro del propio objeto
std::string largo(100, '*'); // 100 caracteres: van al montón
std::println("sizeof(x) = {}, sizeof(v) = {}, sizeof(largo) = {}", sizeof x, sizeof v, sizeof largo);
std::println("v tiene {} elementos y capacidad para {}", v.size(), v.capacity());
std::println("corto: {} caracteres, largo: {}", corto.size(), largo.size());
} // aquí se destruyen v, corto y largo, y se libera su memoriasalidasizeof(x) = 4, sizeof(v) = 24, sizeof(largo) = 32 v tiene 3 elementos y capacidad para 3 corto: 4 caracteres, largo: 100
Fíjate en que sizeof mide solo el objeto, no lo que tiene en el montón: un std::string ocupa 32 bytes en la pila tenga 4 caracteres o 100. Las cadenas cortas, de hasta 15 caracteres en libstdc++, se guardan dentro del propio objeto y ni siquiera tocan el montón; es la optimización de cadenas cortas (small string optimization).
Referencias y punteros
Una referencia es otro nombre para una variable que ya existe. Se declara con &, tiene que inicializarse al crearla y no puede cambiar de destino. Un puntero es una variable que guarda una dirección de memoria: puede apuntar a una cosa y luego a otra, o a nada (nullptr), y para llegar al dato hay que desreferenciarlo con *.
#include <print>
void duplicar(int& n) { n *= 2; } // por referencia: modifica la variable de quien llama
int main()
{
int a = 10;
int& r = a; // r es otro nombre de a
r = 20;
std::println("a = {}", a);
int* p = &a; // p guarda la dirección de a
*p += 5;
std::println("a = {}, *p = {}", a, *p);
int b = 1;
p = &b; // un puntero puede cambiar de destino; una referencia, no
std::println("ahora *p = {}", *p);
duplicar(a);
std::println("tras duplicar, a = {}", a);
int* nada = nullptr;
if (nada == nullptr) {
std::println("nada no apunta a ningún sitio: no se puede desreferenciar");
}
}salidaa = 20 a = 25, *p = 25 ahora *p = 1 tras duplicar, a = 50 nada no apunta a ningún sitio: no se puede desreferenciar
La regla práctica: usa referencias para pasar parámetros (const T& si solo se leen, T& si se modifican) y deja los punteros crudos para cuando de verdad haga falta poder apuntar a «nada» o cambiar de destino. Y en ningún caso uses un puntero crudo para decir quién es el dueño de una memoria: para eso están los punteros inteligentes.
new y delete, y por qué no deberías escribirlos
C++ heredó de C la gestión manual del montón con otra sintaxis: new reserva y construye un objeto, y delete lo destruye y libera la memoria (con new[] y delete[] para arrays). El problema es el mismo que con malloc y free: cada new necesita exactamente un delete, ni uno menos ni uno más, en todos los caminos posibles del programa, incluidos los return anticipados y las excepciones. Basta un olvido para tener una fuga de memoria:
#include <print>
#include <string>
struct Usuario {
std::string nombre;
int edad;
};
bool fuga(int edad)
{
Usuario* u = new Usuario{"Ana", edad};
if (u->edad < 18) {
return false; // se olvida el delete en este camino
}
std::println("{} es mayor de edad", u->nombre);
delete u;
return true;
}
int main()
{
fuga(30);
fuga(12);
}Compilado con -fsanitize=address, al terminar el programa entra en acción LeakSanitizer, que forma parte de AddressSanitizer, y avisa de lo que nadie liberó y de dónde se reservó:
salida con errorAna es mayor de edad
…
==58==ERROR: LeakSanitizer: detected memory leaks
…
Direct leak of 40 byte(s) in 1 object(s) allocated from:
…
#1 0x0000004046cc in fuga(int) /curso/fuga.cpp:11
#2 0x000000404955 in main /curso/fuga.cpp:23
…
SUMMARY: AddressSanitizer: 40 byte(s) leaked in 1 allocation(s).El informe dice que se perdieron 40 bytes (el Usuario: 32 del std::string y 4 del int, más relleno) reservados en la función fuga, en la línea del new. En C++ moderno, la solución no es acordarse del delete: es no escribirlo nunca.
std::unique_ptr: un solo dueño
Un std::unique_ptr es un objeto pequeño que guarda un puntero y, en su destructor, hace el delete por ti. Se crea con std::make_unique, se usa como un puntero normal (* y ->) y libera la memoria pase lo que pase: al salir de la función, en un return anticipado o si se lanza una excepción. No ocupa más que un puntero crudo ni es más lento.
#include <memory>
#include <print>
#include <string>
struct Usuario {
std::string nombre;
int edad;
~Usuario() { std::println(" (se libera {})", nombre); }
};
bool comprobar(int edad)
{
auto u = std::make_unique<Usuario>("Ana", edad); // en el montón, con dueño
if (u->edad < 18) {
return false; // el destructor de u libera la memoria
}
std::println("{} es mayor de edad", u->nombre);
return true; // y aquí también
}
int main()
{
comprobar(30);
comprobar(12);
std::unique_ptr<Usuario> dueno = std::make_unique<Usuario>("Luis", 40);
std::unique_ptr<Usuario> otro = std::move(dueno); // la propiedad pasa a otro
std::println("dueno está vacío: {}", dueno == nullptr);
std::println("otro apunta a {}", otro->nombre);
}salidaAna es mayor de edad (se libera Ana) (se libera Ana) dueno está vacío: true otro apunta a Luis (se libera Luis)
Esta vez no hay fuga: ASan no dice nada. Lo importante es la última parte. Un unique_ptr es el único dueño de su objeto, así que no se puede copiar, porque habría dos dueños y dos delete. Solo se puede mover con std::move: la propiedad pasa al nuevo y el viejo se queda vacío. Si intentas copiarlo, el compilador te para:
El error que te ahorra: copiar un unique_ptr
#include <memory>
int main()
{
auto a = std::make_unique<int>(42);
auto b = a; // ¿dos dueños del mismo int?
}No compila. El error es largo, pero la línea clave es esta:
g++: errorunique_copia.cpp: In function 'int main()':
unique_copia.cpp:6:14: error: use of deleted function 'std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = int; _Dp = std::default_delete<int>]'
6 | auto b = a; // ¿dos dueños del mismo int?«use of deleted function» quiere decir que el constructor de copia de unique_ptr está borrado a propósito (= delete). Sin el compilador, ese código acabaría en un doble delete.
std::shared_ptr y std::weak_ptr: varios dueños
A veces un objeto no tiene un dueño claro: varios componentes lo usan y debe vivir mientras alguno lo necesite. Para eso está std::shared_ptr, que lleva un contador de referencias: cada copia lo suma, cada destrucción lo resta y, cuando llega a cero, se libera el objeto. Se crea con std::make_shared.
#include <memory>
#include <print>
#include <string>
struct Documento {
std::string titulo;
~Documento() { std::println(" (se libera {})", titulo); }
};
int main()
{
auto doc = std::make_shared<Documento>("informe.txt");
std::println("dueños: {}", doc.use_count());
{
auto editor = doc; // copia: otro dueño
auto visor = doc;
std::println("dueños: {}", doc.use_count());
} // editor y visor se destruyen
std::println("dueños: {}", doc.use_count());
doc.reset(); // el último suelta el objeto
std::println("fin de main");
}salidadueños: 1 dueños: 3 dueños: 1 (se libera informe.txt) fin de main
Tiene un precio: el contador se actualiza de forma atómica (para que funcione entre hilos) y vive en un bloque aparte, así que shared_ptr es más pesado que unique_ptr. Y tiene una trampa. Si dos objetos se apuntan entre sí con shared_ptr, ninguno llega nunca a cero: es un ciclo, y la memoria se pierde aunque no quede ninguna variable que los use.
#include <memory>
#include <print>
#include <string>
struct Persona {
std::string nombre;
std::shared_ptr<Persona> pareja; // dueño de la otra persona
~Persona() { std::println(" (se libera {})", nombre); }
};
void crear_pareja()
{
auto ana = std::make_shared<Persona>("Ana");
auto luis = std::make_shared<Persona>("Luis");
ana->pareja = luis;
luis->pareja = ana; // ciclo: cada uno mantiene vivo al otro
std::println("dueños de Ana: {}", ana.use_count());
}
int main()
{
crear_pareja();
std::println("fin de main");
}salida con errordueños de Ana: 2
fin de main
…
==19==ERROR: LeakSanitizer: detected memory leaks
…
Indirect leak of 64 byte(s) in 1 object(s) allocated from:
…
#9 0x000000404701 in crear_pareja() /curso/ciclo.cpp:14
#10 0x000000404967 in main /curso/ciclo.cpp:22
…
Indirect leak of 64 byte(s) in 1 object(s) allocated from:
…
#9 0x0000004046f0 in crear_pareja() /curso/ciclo.cpp:13
#10 0x000000404967 in main /curso/ciclo.cpp:22
…
SUMMARY: AddressSanitizer: 128 byte(s) leaked in 2 allocation(s).Los destructores no llegan a ejecutarse y LeakSanitizer encuentra los dos bloques perdidos, de 64 bytes cada uno (la persona y el contador de make_shared van juntos). Los marca como fugas «indirectas» porque a cada uno solo se llegaba desde el otro. La solución es que uno de los dos lados no sea dueño: std::weak_ptr observa un objeto gestionado por shared_ptr sin sumar al contador, y para usarlo hay que pedirle un shared_ptr temporal con lock(), que sale vacío si el objeto ya no existe.
#include <memory>
#include <print>
#include <string>
struct Persona {
std::string nombre;
std::weak_ptr<Persona> pareja; // observa, pero no es dueño
~Persona() { std::println(" (se libera {})", nombre); }
};
int main()
{
auto ana = std::make_shared<Persona>("Ana");
{
auto luis = std::make_shared<Persona>("Luis");
ana->pareja = luis;
luis->pareja = ana;
if (auto p = ana->pareja.lock()) {
std::println("la pareja de Ana es {}", p->nombre);
}
} // Luis ya no tiene dueños
if (ana->pareja.expired()) {
std::println("la pareja de Ana ya no existe");
}
}salidala pareja de Ana es Luis (se libera Luis) la pareja de Ana ya no existe (se libera Ana)
La semántica de movimiento
Copiar un std::vector con un millón de elementos significa reservar otro bloque y copiar un millón de números. Muchas veces no hace falta, porque el original no se va a volver a usar: por ejemplo, al devolverlo desde una función o al meterlo en otro contenedor. Desde C++11, en esos casos los objetos se pueden mover: el destino se queda con el bloque de memoria del origen, que queda vacío pero en un estado válido. Es copiar un puntero en vez de un millón de números.
std::move no mueve nada por sí mismo: solo convierte su argumento en un «valor que se puede saquear» (un rvalue), y eso hace que se elija el constructor o la asignación de movimiento en lugar de los de copia. Se ve bien con una clase que avisa de lo que le pasa:
#include <print>
#include <string>
#include <utility>
#include <vector>
class Buffer {
public:
explicit Buffer(std::string nombre) : nombre_{std::move(nombre)}, datos_(1'000'000, 0) {}
Buffer(const Buffer& otro) : nombre_{otro.nombre_ + " (copia)"}, datos_{otro.datos_}
{
std::println("copiar {}: {} elementos", otro.nombre_, datos_.size());
}
Buffer(Buffer&& otro) noexcept : nombre_{std::move(otro.nombre_)}, datos_{std::move(otro.datos_)}
{
std::println("mover {}: no se copia nada", nombre_);
}
std::size_t tam() const { return datos_.size(); }
private:
std::string nombre_;
std::vector<int> datos_;
};
int main()
{
Buffer a{"a"};
Buffer b = a; // copia
Buffer c = std::move(a); // movimiento
std::println("tamaños: a = {}, b = {}, c = {}", a.tam(), b.tam(), c.tam());
std::vector<Buffer> lista;
lista.reserve(2);
lista.push_back(std::move(b));
lista.emplace_back("d"); // se construye directamente dentro del vector
std::println("en la lista: {}", lista.size());
}salidacopiar a: 1000000 elementos mover a: no se copia nada tamaños: a = 0, b = 1000000, c = 1000000 mover a (copia): no se copia nada en la lista: 2
Tras el movimiento, a sigue existiendo pero con su vector vacío: un objeto del que se ha movido está en un estado «válido pero no especificado», y lo único seguro es destruirlo o asignarle un valor nuevo. El noexcept del constructor de movimiento no es decorativo: std::vector solo mueve sus elementos al crecer si ese constructor promete no lanzar excepciones; si no, los copia para no perder datos a medias.
Buffer define sus constructores solo para enseñar qué pasa. En tu código, si los miembros de una clase ya saben copiarse y moverse (std::string, std::vector, std::unique_ptr…), no escribas ninguno: el compilador genera los correctos. Solo una clase que gestione un recurso a mano necesita los cinco (destructor, dos de copia y dos de movimiento), y lo normal es que esa clase sea un envoltorio pequeño y todo lo demás use la regla del cero.Referencias colgantes y uso tras liberar
Los punteros inteligentes resuelven quién libera, pero C++ todavía permite quedarse con una referencia o un puntero a algo que ya no existe: una referencia colgante (dangling reference). El caso más típico es devolver una referencia a una variable local, que muere al salir de la función. Por suerte, g++ lo detecta:
El error que te ahorra: devolver una referencia a una variable local
#include <string>
const std::string& saludo(const std::string& nombre)
{
std::string texto = "Hola, " + nombre;
return texto; // texto se destruye al salir
}
int main()
{
const std::string& s = saludo("Ana");
return static_cast<int>(s.size());
}g++: avisoref_local.cpp: In function 'const std::string& saludo(const std::string&)':
ref_local.cpp:6:12: warning: reference to local variable 'texto' returned [-Wreturn-local-addr]
6 | return texto; // texto se destruye al salir
| ^~~~~Es un aviso, no un error: compila, y si lo ejecutas lee memoria que ya no es suya. Devuelve std::string por valor; gracias al movimiento y a la eliminación de copias no cuesta nada.
Otros casos se le escapan al compilador. std::string_view es una vista de una cadena que no es suya, y si la cadena original desaparece la vista queda colgando. Aquí la cadena es un temporal que muere al final de la línea:
#include <print>
#include <string>
#include <string_view>
std::string nombre_largo()
{
return "Margarita Fernández de la Fuente"; // más de 15 caracteres: va al montón
}
int main()
{
std::string_view v = nombre_largo(); // el string temporal muere al acabar esta línea
std::println("{}", v); // lee memoria ya liberada
}salida con error==242==ERROR: AddressSanitizer: heap-use-after-free on address 0x70a2273e0010 at pc 0x746228669a97 bp 0x7ffff82e53b0 sp 0x7ffff82e4b70
READ of size 33 at 0x70a2273e0010 thread T0
…
#10 0x000000404898 in main /curso/vista_colgante.cpp:13
…
freed by thread T0 here:
…
#7 0x0000004047e2 in main /curso/vista_colgante.cpp:12
…
previously allocated by thread T0 here:
…
#8 0x0000004046c1 in nombre_largo[abi:cxx11]() /curso/vista_colgante.cpp:7
#9 0x0000004047a0 in main /curso/vista_colgante.cpp:12
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/vista_colgante.cpp:13 in mainAddressSanitizer lo explica entero: un heap-use-after-free, la lectura en main, quién liberó la memoria (el destructor del temporal, llamado desde main) y quién la reservó (nombre_largo). Sin ASan, este programa a veces imprime el nombre, a veces basura y a veces se cuelga: el peor tipo de error, el que no siempre se ve. Lo mismo pasa con un iterador o un puntero a un elemento de un std::vector que luego crece, como viste en la lección 4.
Ojo con la seguridad: el uso tras liberar sigue existiendo en C++
Los punteros inteligentes eliminan casi todas las fugas y los dobles delete, pero no las referencias colgantes: string_view, span, iteradores, referencias devueltas y lambdas que capturan por referencia pueden sobrevivir al objeto que observan. El uso tras liberar es una de las clases de fallo más explotadas en navegadores y sistemas operativos escritos en C++: si un atacante logra que se reserve un bloque nuevo donde estaba el viejo, el puntero colgante lee o escribe sus datos. Las defensas: que las vistas y referencias no vivan más que su dueño (nunca guardes un string_view de algo que no controlas), devolver por valor, compilar las pruebas con -fsanitize=address y no ignorar los avisos -Wdangling-reference y -Wreturn-local-addr de g++.
Valgrind: lo mismo sin recompilar
AddressSanitizer necesita recompilar el programa. Valgrind ejecuta el programa normal dentro de una máquina virtual que vigila cada acceso a memoria: es entre 10 y 50 veces más lento, pero sirve con cualquier ejecutable. Con el primer programa de la fuga, compilado sin sanitizadores:
#include <string>
struct Usuario {
std::string nombre;
int edad;
};
int main()
{
Usuario* u = new Usuario{"Ana", 12};
if (u->edad < 18) {
return 0; // se olvida el delete
}
delete u;
}valgrind --leak-check=full ./fuga_valgrind
salida con error==77== 40 bytes in 1 blocks are definitely lost in loss record 1 of 1 ==77== at 0x4844F93: operator new(unsigned long) (vg_replace_malloc.c:487) ==77== by 0x4021DC: main (fuga_valgrind.cpp:10) … ==77== LEAK SUMMARY: ==77== definitely lost: 40 bytes in 1 blocks … ==77== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
«definitely lost» son los bytes que nadie puede ya liberar, y debajo aparece la pila de la reserva: operator new, llamado desde main en la línea del new. Valgrind no detecta los errores en la pila ni en las variables globales, que ASan sí; por eso en las pruebas conviene usar ASan y reservar Valgrind para ejecutables que no puedes recompilar.
Qué usar en cada caso
- NecesitasUsaPor qué
- Un objeto local o un miembroLa variable tal cual:
Usuario u;Vive en la pila o dentro de su dueño; nada que liberar - Una colección
std::vector,std::string,std::map…Gestionan su memoria del montón solos - Un objeto en el montón con un dueño
std::unique_ptr+std::make_uniqueLibera solo; no se copia, se mueve - Varios dueños de verdad
std::shared_ptr+std::make_sharedContador de referencias; más caro - Observar sin ser dueñoReferencia, puntero crudo o
std::weak_ptrNunca hacendelete;weak_ptrsabe si el objeto murió - Pasar un parámetro
const T&oT&Sin copias y sin dudas sobre quién es el dueño newydeletea manoCasi nuncaSolo dentro de una clase pequeña que gestione un recurso
Retos
Reto 1. Escribe una función crear(int n) que devuelva un std::unique_ptr<std::vector<int>> con los números del 1 al n, y úsala desde main para imprimir la suma. Compílalo con ASan para comprobar que no hay fugas.
Ver la solución
#include <memory>
#include <numeric>
#include <print>
#include <vector>
std::unique_ptr<std::vector<int>> crear(int n)
{
auto v = std::make_unique<std::vector<int>>();
for (int i = 1; i <= n; ++i) {
v->push_back(i);
}
return v; // se mueve al que llama; no hace falta std::move
}
int main()
{
auto numeros = crear(100);
std::println("{} números, suma {}", numeros->size(), std::accumulate(numeros->begin(), numeros->end(), 0));
}salida100 números, suma 5050
Al devolver una variable local, C++ la mueve sola. Aunque en este caso sobraría el unique_ptr: un std::vector<int> devuelto por valor hace lo mismo con menos ruido. El puntero inteligente tiene sentido cuando el objeto es grande y no se puede mover, o cuando hace falta polimorfismo, como verás en la lección 7.
Reto 2. ¿Qué imprime este programa? auto p = std::make_shared<int>(5); auto q = p; std::weak_ptr<int> w = p; p.reset(); std::println("{} {}", q.use_count(), w.expired());
Ver la solución
#include <memory>
#include <print>
int main()
{
auto p = std::make_shared<int>(5);
auto q = p;
std::weak_ptr<int> w = p;
p.reset();
std::println("{} {}", q.use_count(), w.expired());
}salida1 false
Al hacer p.reset() queda un solo dueño, q, así que el contador vale 1. El weak_ptr no cuenta como dueño, y el objeto sigue vivo gracias a q: expired() devuelve false.
Reto 3. Encuentra el fallo: std::vector<std::string> v{"uno"}; std::string& primero = v[0]; v.push_back("dos"); std::println("{}", primero);
Ver la solución
#include <print>
#include <string>
#include <vector>
int main()
{
std::vector<std::string> v{"uno"};
std::string& primero = v[0];
v.push_back("dos"); // el vector crece y mueve sus elementos a otro bloque
std::println("{}", primero);
}salida con error==185==ERROR: AddressSanitizer: heap-use-after-free on address 0x71239b7e0040 at pc 0x000000410347 bp 0x7ffe53b45af0 sp 0x7ffe53b45ae8
READ of size 8 at 0x71239b7e0040 thread T0
…
#9 0x000000404a30 in main /curso/reto3.cpp:10
…
#9 0x00000040493e in main /curso/reto3.cpp:9
…
#7 0x000000404861 in main /curso/reto3.cpp:7
…
SUMMARY: AddressSanitizer: heap-use-after-free /curso/reto3.cpp:10 in mainAl hacer push_back sin capacidad libre, el vector reserva un bloque más grande, mueve los elementos y libera el viejo, así que primero queda colgando. Guarda el índice en lugar de la referencia, o copia la cadena si la necesitas después.
Todo el código de esta lección se compiló con GCC 15.3 (C++23, -std=c++23 -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con g++ --version: hace falta GCC 14 o más nuevo (o Clang 19 con libstdc++ 14) para std::print y el resto de C++23.
