Memoria en C++: referencias, punteros y punteros inteligentes

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.

A la izquierda, la pila, con la variable v del vector: un puntero a los datos, el tamaño 3 y la capacidad 3. A la derecha, el montón, con los tres enteros 1, 2 y 3 seguidos. Una flecha va del puntero de v a los datos. La pila se libera sola al salir de la función; el montón lo libera el destructor del vectorDónde vive cada cosa en std::vector<int> v{1, 2, 3}La pila (stack)automática: se libera al salirv (24 bytes)puntero a los datostamaño: 3capacidad: 3int x = 7; también vive aquíEl montón (heap)lo pide y lo libera el vector1233 int seguidos, 12 bytesel destructor de v los devuelve

El objeto 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 memoria
salidasizeof(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.

A la izquierda, copiar: después de b igual a a, cada variable apunta a su propio bloque con el texto, así que hubo que reservar memoria y copiar. A la derecha, mover: después de b igual a std::move de a, b apunta al bloque que era de a y a queda vacía; no se copia nadaCopiar frente a mover un std::string largoCopiar: b = a;a y b tienen cada uno su textoabtexto largotexto largodos bloques: se reserva y se copiaMover: b = std::move(a);b se queda con el bloque de aabtexto largoa queda vacío; no se copia nada

Mover no copia los datos: pasa la propiedad del bloque del montón de un objeto a otro. Esquema: decodigo.com.

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.

La regla del cero. La clase 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 main

AddressSanitizer 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++.

Más en Los fallos de seguridad típicos de C++.

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ónstd::vector, std::string, std::map…Gestionan su memoria del montón solos
  • Un objeto en el montón con un dueñostd::unique_ptr + std::make_uniqueLibera solo; no se copia, se mueve
  • Varios dueños de verdadstd::shared_ptr + std::make_sharedContador de referencias; más caro
  • Observar sin ser dueñoReferencia, puntero crudo o std::weak_ptrNunca hacen delete; weak_ptr sabe si el objeto murió
  • Pasar un parámetroconst T& o T&Sin copias y sin dudas sobre quién es el dueño
  • new y delete a 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 main

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