CodeGym /Cursos /C++ SELF /Invalidación en std::vector: reallocation, erase

Invalidación en std::vector: reallocation, erase

C++ SELF
Nivel 41, Lección 1
Disponible

1. Invalidación

Cuando empiezas a trabajar con std::vector, parece muy «casero»: colocas elementos, los obtienes, todo lógico. Y luego tomas un puntero Task* p = &tasks[0];, añades una nueva tarea, y de repente p se convierte en «una dirección que fue verdad en su momento». Y eso es la invalidación.

La invalidación (invalidate) en el contexto de contenedores es la situación en la que tu vinculación a un elemento (referencia, puntero, iterador) ya no está obligada a señalar al mismo elemento que señalaba antes. A veces empieza a apuntar a otro elemento, a veces — a «ninguna parte». Y «ninguna parte» en C++ a menudo no significa un error elegante, sino un potencial UB: valores extraños, caídas, «funciona en mi portátil» y un colapso inesperado en producción.

Informalmente puedes pensarlo así: guardaste la ruta al buzón («aquí está la dirección»), y el vector se mudó a otro piso (o reordenó los muebles), y tu ruta ahora lleva a la pared.

2. Por qué std::vector «se muda»

std::vector tiene un superpoder: los elementos están almacenados en un bloque contiguo de memoria (como un array). Por eso vector suele ser muy rápido: al procesador le gusta leer datos consecutivos, la cache lo agradece y todo va mejor. El propio estándar y las discusiones alrededor subrayan que vector es un contenedor contiguous, y existe incluso la categoría de contiguous iterators para vector, array y string.

Pero a cambio de esa velocidad pagamos que el vector a veces deba hacer una operación costosa: la reallocación (reallocation). La idea es simple: el vector guarda dos números — size() (cuántos elementos realmente hay) y capacity() (cuántos elementos caben en el buffer actual sin mudarse). En cuanto añades un elemento y size() supera capacity(), el vector asigna un buffer nuevo más grande, mueve los elementos y libera el antiguo.

Desde el punto de vista de tus punteros/referencias eso se siente duro: «ayer vivía en Lenina 10, hoy en Pushkina 42; espero que no sigas enviando cartas a la dirección antigua».

3. reallocation: cuándo ocurre y qué rompe

Lo más traicionero de la reallocation es que muchas veces no la ves. Escribes push_back, el código compila, la prueba pasa… y luego, con otro tamaño de datos, la capacity() resulta pequeña y el vector se muda. Ahí empieza la «magia» (en el sentido de «magia para quienes depuran de noche»).

Idea clave: tras una reallocation todas las direcciones antiguas de los elementos quedan inválidas. Esto afecta a punteros, referencias e iteradores a los elementos.

Puedes comprobarlo visualmente imprimiendo la dirección del primer elemento antes y después de añadir.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{10, 20, 30};

    std::cout << "addr before: " << &v[0] << "\n";
    v.push_back(40); // puede provocar reallocation
    std::cout << "addr after:  " << &v[0] << "\n";
}

A veces las direcciones coinciden (no hubo reallocation), otras veces difieren (hubo). Y ese «a veces» es la razón por la que los bugs por invalidación son tan desagradables: suelen depender del tamaño de los datos e incluso de cómo el enlazador/allocator asignó la memoria ese día.

Para visualizarlo mejor, puedes comparar data():

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v;
    v.push_back(1);

    std::cout << "data: " << v.data() << "\n";
    v.push_back(2); // posible mudanza
    std::cout << "data: " << v.data() << "\n";
}

Si data() cambió — es casi una señal segura de que las antiguas «vinculaciones» a los elementos ya no se deben usar.

Mini‑diagrama: qué ocurre en una reallocation

Antes (capacity = 3):
[10][20][30]
 ^ direcciones por aquí

Después (capacity aumentó, por ejemplo, a 6):
[10][20][30][40][  ][  ]
 ^ nuevo buffer en otra ubicación
(el buffer antiguo ha sido liberado)

Los punteros antiguos ahora apuntan a memoria liberada. Desde la perspectiva del lenguaje eso es «zona prohibida»: puede «funcionar por accidente», puede provocar caídas o corromper datos.

4. erase: desplazamiento de elementos e invalidación

Con erase la traición es distinta. Al eliminar un elemento del medio del vector, std::vector debe mantener la contigüidad. Así que hace lo que haría un array: desplaza la cola hacia la izquierda para cerrar el hueco.

Eso significa que las direcciones de los elementos después del eliminado cambian. Y los índices también «se mueven».

#include <vector>

int main() {
    std::vector<int> v{10, 20, 30, 40};

    int* p30 = &v[2];          // dirección del elemento 30
    v.erase(v.begin() + 1);    // eliminamos 20, 30 se desplazó

    // *p30 = 999; // potencialmente UB: p30 pudo quedar inválido
    (void)p30;
}

Aquí hay un punto psicológico importante: «pero 30 sigue en el vector!» — sí, sigue como valor, pero no está obligado a seguir en la misma dirección. Te vinculaste a una dirección, no a la idea del número «30».

Incluso en materiales del estándar hay debates sobre la precisión de las formulaciones acerca de erase y validez (incluso issues sobre la especificación de erase en [vector.modifiers]). Es una buena señal de que el tema es sutil e importante.

Mini‑diagrama: qué hace erase

Antes:
index: 0   1   2   3
      [10][20][30][40]

erase(1) eliminamos 20

Después:
index: 0   1   2
      [10][30][40]
          ^
       la dirección de "30" ahora es distinta (se mudó a la posición 1)

5. Por qué const no salva

Aquí suele aparecer la confusión típica: el estudiante toma honestamente const int& r = v[0];, y luego piensa «es const, así que es seguro». Lamentablemente, const solo dice una cosa: «no puedes modificar el valor a través de r». Pero no garantiza estabilidad de la dirección, reedición de memoria, tiempo de vida ni que el vector no vaya a realojar su buffer.

#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    const int& r = v[0];
    v.push_back(4); // puede invalidar r

    // int x = r; // potencialmente UB
    (void)r;
}

Es parecido a que te den «un pase de solo lectura» para un edificio. Pero si el edificio se mudó a otra ciudad, tu pase no hace que la dirección antigua vuelva a ser real.

6. Ejemplo práctico: tareas y «elemento seleccionado»

Para que no parezca que esto va solo de números abstractos, veamos un pequeño fragmento de nuestra app educativa. Supongamos que tenemos un modelo Task y una lista de tareas en std::vector.

#include <string>
#include <vector>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "Read C++ book", false});
}

Ahora imaginemos que hacemos una función de búsqueda y devolvemos un puntero a la tarea si la encontramos.

#include <vector>

Task* FindTaskById(std::vector<Task>& tasks, int id) {
    for (Task& t : tasks) {
        if (t.id == id) return &t;
    }
    return nullptr;
}

Mientras usamos ese puntero «aquí y ahora» — todo bien. El problema empieza si decidimos guardar ese puntero y seguir modificando tasks.

Escenario‑trampa:

#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, "A", false});

    Task* selected = &tasks[0];
    tasks.push_back(Task{2, "B", false}); // puede causar reallocation

    // selected->done = true; // potencialmente UB
    (void)selected;
}

Para un programador novel esto parece lógico: selecciono una tarea, añado otra y luego marco la seleccionada como hecha. Pero en memoria puede significar: «guardé una dirección, luego el vector se mudó, y sigo escribiendo en la dirección antigua».

Y otro inconveniente: a veces «parece funcionar», porque no ocurrió reallocation (capacity() tenía margen). Por eso ese bug puede vivir mucho y aparecer en el peor momento.

Qué puede fallar y por qué es difícil depurarlo

Cuando usas una referencia/puntero/iterador invalidado, C++ normalmente no te avisa. El compilador no sabe que en tiempo de ejecución hiciste push_back y «rompiste» la vinculación, y el programa no está obligado a caer de inmediato. Puede mostrar el viejo valor (porque la memoria aún no fue reutilizada), mostrar basura (porque ya está ocupada), fallar (si accedes a una zona protegida) o corromper otros datos y fallar después (esto es especialmente «divertido»).

Es una de las razones del meme sobre C++: «Undefined Behavior — es cuando el programa hace algo que no esperabas, y además lo hace con confianza».

La buena noticia es que std::vector se comporta de forma predecible si sigues un modelo simple: todo lo que hace que el vector se mude (reallocation) rompe todas las vinculaciones a los elementos; todo lo que hace que el vector desplace elementos (erase en el medio) rompe las vinculaciones a los elementos que se han movido.

Mapa de riesgos por operaciones de std::vector

Abajo hay una tabla práctica «para la cabeza», sin aspirar a convertirte en un walking standard. Piensa en ella como señales de tráfico: no explican la física, pero reducen mucho los accidentes.

Operación sobre std::vector Qué puede pasar internamente Riesgo para T* / T& / iterator sobre elementos
push_back
,
emplace_back
a veces reallocation a veces rompe todas las vinculaciones
reserve(n)
puede provocar reallocation ahora mismo si hubo mudanza — rompe todas las vinculaciones
crecimiento vía
resize
(hacia más)
puede causar reallocation a veces rompe todas las vinculaciones
erase(pos)
desplazamiento de elementos tras
pos
rompe las vinculaciones a elementos desplazados y al eliminado
erase(first, last)
eliminación de un rango + desplazamiento de la cola similar: rompe las vinculaciones "tras el rango"
clear()
elimina lógicamente los elementos las vinculaciones a elementos ya no tienen sentido
lectura:
operator[]
, range-for
no cambia nada seguro, siempre que el vector no se modifique

Fíjate en la sutileza: «a veces rompe» no es «da igual». Significa «el código se comportará distinto con distintos tamaños de datos».

Cómo escribir código para no tropezar con la invalidación

Es útil adquirir la costumbre: la vinculación a un elemento de un vector debe vivir muy poco. El tiempo de vida ideal de un puntero/referencia/iterador a un elemento de vector es «dentro de un pequeño tramo de código donde el vector no se modifica».

Por ejemplo, este código suele ser más seguro a nivel psicológico y arquitectónico:

#include <vector>

void MarkFirstDone(std::vector<Task>& tasks) {
    if (tasks.empty()) return;

    Task& t = tasks[0];  // tomamos referencia
    t.done = true;       // la usamos de inmediato
    // ... y no hacemos más push_back/erase aquí
}

En cambio, si ves una referencia/puntero que vive mucho tiempo y cerca hay push_back o erase, el cerebro debe decir «alto, aquí puede haber invalidación».

#include <vector>

void Suspicious(std::vector<Task>& tasks) {
    if (tasks.empty()) return;

    Task* p = &tasks[0];                   // vinculación
    tasks.push_back(Task{99, "X", false}); // potencial mudanza

    // p->done = true; // zona peligrosa: p pudo quedar inválido
    (void)p;
}

En esta charla no vamos a convertirlo en una «religión arquitectónica» ni a decidir exactamente con qué sustituir direcciones de vida larga. Ahora lo importante es fijar la idea: la dirección de un elemento en std::vector es la dirección "ahora mismo", no una identidad eterna.

7. Errores típicos al invalidar en std::vector

Error nº1: almacenar T*/T&/iterador a un elemento del vector "entre acciones" y luego hacer push_back.
Clásico: «seleccioné un elemento», «guardé su puntero», «añadí otro elemento», «¿por qué se rompió todo?». El problema es la posible reallocation: el vector se muda y el puntero queda basura. Visualmente el código parece correcto, por eso el error perdura.

Error nº2: pensar que const hace la vinculación segura.
const T& y const T* prohíben escribir a través de ese acceso, pero no garantizan estabilidad de la dirección ni evitan que el buffer se realoje. Si mantienes una referencia const a un elemento y cerca hay operaciones de crecimiento del vector, sigue siendo potencialmente peligroso.

Error nº3: tras erase seguir usando las "direcciones antiguas" de elementos que estaban a la derecha del eliminado.
Con erase los elementos se desplazan. Incluso si el objeto que quieres sigue en la colección «lógicamente», físicamente pudo quedar en otra dirección. Esto rompe no solo punteros, sino también iteradores y referencias.

Error nº4: "si tengo reserve, las direcciones son estables para siempre".
reserve ayuda a reducir mudanzas, pero no convierte las direcciones en garantía de por vida. Basta superar la capacidad reservada una vez y la mudanza ocurrirá. Además, erase no se soluciona con reserve: es otra causa de cambio de direcciones.

Error nº5: verificar el bug "con datos pequeños" y concluir "entonces está bien".
Con tamaños pequeños la capacity() suele ser suficiente, push_back no provoca reallocation, y los punteros «por accidente no se rompen». Luego das entrada real al programa y todo cambia. Para temas de invalidación esto es especialmente típico: los bugs no se manifiestan siempre ni inmediatamente.

1
Tarea
C++ SELF, nivel 41, lección 1
Bloqueada
Espacio en la mochila
Espacio en la mochila
1
Tarea
C++ SELF, nivel 41, lección 1
Bloqueada
Selección en la lista de reproducción
Selección en la lista de reproducción
1
Tarea
C++ SELF, nivel 41, lección 1
Bloqueada
Limpieza de spam
Limpieza de spam
1
Tarea
C++ SELF, nivel 41, lección 1
Bloqueada
Comandos de la lista de tareas
Comandos de la lista de tareas
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION