JavaRush /Курсы /C++ SELF /Инвалидация в std::vector: reallocation, erase

Инвалидация в std::vector: reallocation, erase

C++ SELF
41 уровень , 1 лекция
Открыта

1. Инвалидация

Когда вы впервые начинаете работать с std::vector, он кажется очень «домашним»: положили элементы, достали элементы, всё логично. А потом вы берёте указатель Task* p = &tasks[0];, добавляете новую задачу, и внезапно p превращается в «адрес, который когда-то был правдой». И вот это и есть инвалидация.

Инвалидация (invalidate) в контексте контейнеров — это ситуация, когда ваша привязка к элементу (ссылка, указатель, итератор) больше не обязана указывать на тот же элемент, на который указывала раньше. Иногда она начинает указывать на другой элемент, иногда — в «никуда». А «никуда» в C++ часто означает не красивую ошибку, а потенциальный UB: странные значения, падения, «работает у меня на ноутбуке» и внезапный крах в проде.

Неформально можно думать так: вы сохранили маршрут к ящику («вот адрес»), а вектор взял и переехал в другую квартиру (или переставил мебель), и ваш маршрут теперь ведёт к стене.

2. Почему std::vector «переезжает»

У std::vector есть суперсила: элементы лежат в одном непрерывном куске памяти (как массив). Именно поэтому vector так часто быстрый: процессору удобно читать подряд лежащие данные, кеш радуется, жизнь удалась. Сам стандарт и обсуждения вокруг него подчёркивают, что vectorcontiguous-контейнер, и даже отдельно отмечается категория contiguous iterators для vector, array и string.

Но за эту скорость мы платим тем, что вектор иногда вынужден делать дорогую операцию: перевыделение (reallocation). Смысл простой: вектор хранит два числа — size() (сколько элементов реально) и capacity() (сколько элементов помещается в текущем буфере без переезда). Как только вы добавляете элемент и size() становится больше capacity(), вектор выделяет новый, более большой буфер, переносит туда элементы и освобождает старый.

С точки зрения ваших указателей/ссылок это выглядит грубо: «вчера я жил по адресу Шелдон-стрит 10, сегодня — по адресу Пенни-лейн 42; надеюсь, вы не продолжаете присылать письма на старый адрес».

3. reallocation: когда возникает и что ломается

Самое коварное в reallocation — что вы часто его не видите. Вы пишете push_back, код компилируется, тест проходит… а потом при другом размере данных capacity() оказывается маленькой, и вектор переезжает. И вот тут начинает происходить «магия» (в смысле «магия для любителей дебага ночью»).

Ключевая идея: после reallocation все старые адреса элементов становятся недействительными. Это касается указателей, ссылок и итераторов на элементы.

Поймать это глазами можно так: просто распечатать адрес первого элемента до и после добавления.

#include <iostream>
#include <vector>

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

    std::cout << "addr before: " << &v[0] << "\n";
    v.push_back(40); // может вызвать reallocation
    std::cout << "addr after:  " << &v[0] << "\n";
}

Иногда адреса совпадут (reallocation не случился), иногда будут разными (случился). И вот это «иногда» — причина, почему баги с инвалидированием такие неприятные: они часто зависят от размера входных данных и даже от того, как именно компоновщик/аллокатор распределил память сегодня.

Чтобы было ещё нагляднее, можно сравнить 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); // возможно, переезд
    std::cout << "data: " << v.data() << "\n";
}

Если data() поменялся — это почти железный сигнал, что старые «привязки» к элементам использовать нельзя.

Мини‑схема: что происходит при reallocation

Было (capacity = 3):
[10][20][30]
 ^ адреса где-то тут

Стало (capacity выросла, например, до 6):
[10][20][30][40][  ][  ]
 ^ новый буфер в другом месте
(старый буфер освобождён)

Старые указатели теперь указывают в освобождённую память. С точки зрения языка это «запрещённая зона»: может «случайно работать», может падать, может портить данные.

4. erase: сдвиг элементов и инвалидация

С erase всё коварно по-другому. При удалении элемента из середины вектора std::vector должен сохранить непрерывность хранения. А значит, он делает то, что сделал бы обычный массив: сдвигает хвост влево, чтобы закрыть дырку.

Это означает, что адреса элементов после удалённого меняются. И индексы тоже «едут».

#include <vector>

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

    int* p30 = &v[2];          // адрес элемента 30
    v.erase(v.begin() + 1);    // удалили 20, 30 сдвинулся

    // *p30 = 999; // потенциально UB: p30 мог стать невалидным
    (void)p30;
}

Здесь важен психологический момент: «но ведь 30 осталась в векторе!» — да, осталась как значение, но не обязана остаться по тому же адресу. Вы привязались к адресу, а не к идее числа «30».

Даже в материалах по стандарту встречаются обсуждения точности формулировок про erase и валидность (вплоть до отдельных issues про спецификакацию erase в [vector.modifiers]). Это хороший маркер, что тема действительно тонкая и важная.

Мини‑схема: что делает erase

Было:
index: 0   1   2   3
      [10][20][30][40]

erase(1) удаляем 20

Стало:
index: 0   1   2
      [10][30][40]
          ^
       адрес "30" теперь другой (он переехал на позицию 1)

5. Почему const не спасает

Здесь часто происходит типичная путаница: студент честно берёт const int& r = v[0];, а потом думает «ну это же const, значит безопасно». Увы, const говорит только одно: «через r нельзя менять значение». Но он ничего не гарантирует про стабильность адреса, про переезды, про время жизни и про то, что вектор не решит перераспределить память.

#include <vector>

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

    const int& r = v[0];
    v.push_back(4); // может инвалидировать r

    // int x = r; // потенциально UB
    (void)r;
}

Это похоже на ситуацию: вам дали «пропуск только для чтения» в здание. Но если здание переехало в другой город, ваш пропуск не делает старый адрес снова реальным.

6. Практический пример: задачи и «выбранный элемент»

Чтобы не было ощущения «это всё про абстрактные числа», давайте посмотрим на маленький фрагмент нашего учебного приложения. Предположим, у нас есть модель задачи и список задач в 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});
}

Теперь представим, что мы сделали функцию поиска и возвращаем указатель на задачу, если нашли.

#include <vector>

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

Пока мы используем этот указатель «здесь и сейчас» — всё хорошо. Проблема начинается, если мы решаем его сохранить и продолжить менять tasks.

Сценарий‑ловушка:

#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}); // может вызвать reallocation

    // selected->done = true; // потенциально UB
    (void)selected;
}

С точки зрения программиста‑новичка это выглядит «логично»: я выбрал задачу, потом добавил ещё одну, потом отмечаю выбранную как выполненную. Но с точки зрения памяти это может означать: «я запомнил адрес, потом вектор переехал, а я продолжаю писать по старому адресу».

И отдельная боль: иногда это «вроде работает», потому что reallocation не случился (capacity() была с запасом). Поэтому такой баг может жить долго и проявляться в самый неприятный момент.

Что может пойти не так и почему это сложно отлаживать

Когда вы используете инвалидированную ссылку/указатель/итератор, C++ обычно не делает вам замечание. Компилятор не знает, что в рантайме вы сделали push_back и «сломали» привязку, а сама программа не обязана падать сразу. Она может показать старое значение (потому что память пока не переиспользована), показать мусор (потому что память уже занята чем-то другим), упасть (если вы залезли в защищённую область) или тихо испортить другие данные и упасть позже (это особенно «весело»).

Это одна из причин, почему вокруг C++ есть мем: «Undefined Behavior — это когда программа делает то, чего вы не ожидали, и ещё делает это уверенно».

Хорошая новость в том, что std::vector ведёт себя предсказуемо, если держать простую модель: всё, что заставляет вектор переехать (reallocation), ломает все привязки к элементам; всё, что заставляет вектор сдвигать элементы (erase в середине), ломает привязки к элементам, которые сдвинулись.

Карта рисков по операциям std::vector

Ниже — практическая табличка «для мозга», без попытки сделать из вас ходячий стандарт. Думайте о ней как о дорожных знаках: не объясняют физику, но сильно уменьшают число аварий.

Операция с std::vector Что может случиться внутри Риск для T* / T& / iterator на элементы
push_back
,
emplace_back
иногда reallocation иногда ломает все привязки
reserve(n)
может вызвать reallocation прямо сейчас если был переезд — ломает все привязки
рост через
resize
(в сторону увеличения)
может вызвать reallocation иногда ломает все привязки
erase(pos)
сдвиг элементов после
pos
ломает привязки к сдвинутым элементам и к удалённому
erase(first, last)
удаление диапазона + сдвиг хвоста аналогично: ломает «после диапазона»
clear()
логически удаляет элементы привязки к элементам больше не имеют смысла
чтение:
operator[]
, range-for
ничего не меняет безопасно, пока вектор не меняется

Обратите внимание на тонкость: «иногда ломает» — это не «можно забить». Это значит «код будет вести себя по-разному при разных размерах данных».

Как писать код, чтобы не наступать на инвалидацию

Полезно выработать привычку: привязка к элементу вектора должна жить очень коротко. Идеальный срок жизни указателя/ссылки/итератора на элемент vector — «в пределах маленького участка кода, где вектор не модифицируется».

Например, такой код обычно безопаснее психологически и архитектурно:

#include <vector>

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

    Task& t = tasks[0];  // взяли ссылку
    t.done = true;       // сразу использовали
    // ... и больше никаких push_back/erase здесь не делаем
}

А вот такой подход всегда должен включать внутреннюю «сигнализацию»: если вы видите, что ссылка/указатель живёт долго и где-то рядом есть push_back или erase, мозг должен сказать «стоп, тут возможно инвалидирование».

#include <vector>

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

    Task* p = &tasks[0];                   // привязка
    tasks.push_back(Task{99, "X", false}); // потенциальный переезд

    // p->done = true; // опасная зона: p мог стать невалидным
    (void)p;
}

В этой лекции мы не будем превращать это в «архитектурную религию» и выбирать, чем именно заменять долгоживущие адреса элементов. Сейчас нам важно закрепить причину: адрес элемента std::vector — это адрес «прямо сейчас», а не вечная идентичность.

7. Типичные ошибки при инвалидировании в std::vector

Ошибка №1: хранить T*/T&/итератор на элемент vector «между действиями», а потом делать push_back.
Это классика: «я выбрал элемент», «я сохранил на него указатель», «я добавил ещё один элемент», «почему у меня сломался космос?». Проблема в возможном reallocation: вектор переезжает, и указатель становится мусором. Визуально код выглядит прилично, поэтому ошибка особенно живучая.

Ошибка №2: думать, что const делает привязку безопасной.
const T& и const T* запрещают запись через этот доступ, но не гарантируют стабильность адреса и не предотвращают переезд буфера. Если вы держите const-ссылку на элемент и рядом есть операции роста вектора, это всё равно потенциально опасно.

Ошибка №3: после erase продолжать пользоваться «старыми адресами» элементов, которые были правее удалённого.
При erase элементы сдвигаются. Даже если «логически» нужный объект остался в коллекции, физически он мог оказаться на другом адресе. Это ломает не только указатели, но и итераторы, и ссылки.

Ошибка №4: «у меня же reserve, значит адреса стабильны навсегда».
reserve действительно помогает уменьшить количество переездов, но он не превращает адреса элементов в пожизненную гарантию. Достаточно один раз превысить зарезервированную ёмкость — и переезд всё равно произойдёт. Плюс, erase вообще не лечится reserve: это другая причина смены адресов.

Ошибка №5: проверять баг «на маленьких данных» и делать вывод «значит всё правильно».
На маленьких размерах capacity() часто хватает, push_back не вызывает reallocation, и указатели «случайно не ломаются». А потом вы даёте программе реальный ввод — и всё начинает вести себя иначе. Для тем про инвалидирование это особенно типично: баги проявляются не сразу и не всегда.

1
Задача
C++ SELF, 41 уровень, 1 лекция
Недоступна
Место в рюкзаке
Место в рюкзаке
1
Задача
C++ SELF, 41 уровень, 1 лекция
Недоступна
Выбор в плейлисте
Выбор в плейлисте
1
Задача
C++ SELF, 41 уровень, 1 лекция
Недоступна
Чистка от спама
Чистка от спама
1
Задача
C++ SELF, 41 уровень, 1 лекция
Недоступна
Команды списка дел
Команды списка дел
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ