1. erase() и инвалидация привязок
Когда вы впервые слышите «удалить элемент из массива», мозг рисует картинку: взял и вычеркнул один квадратик, всё остальное не трогай. У std::vector так нельзя по вполне практичным причинам: он хранит элементы подряд в памяти, без дырок. Поэтому удаление — это почти всегда сдвиг оставшихся элементов влево. А значит, меняются «позиции», и некоторые «запомненные указания» на элементы перестают быть корректными.
Представим список дел: ["купить молоко", "помыть кота", "сдать лабу"]. Если удалить середину, вектор обязан сделать так, чтобы элементы снова лежали подряд: ["купить молоко", "сдать лабу"]. Для вас это выглядит логично, но для итератора, который «смотрел» на элемент «сдать лабу», это уже совсем другая история: он мог стать недействительным или начать указывать на «не то».
Модель без дырок: что физически происходит при erase
Перед тем как писать код, полезно на пару минут включить «режим рентгена» и представить, что такое vector внутри. У него есть буфер памяти, который способен вместить capacity() элементов, и реальное число элементов size() (то, что реально существует). Удаление элемента в середине — это не просто «минус один к size», а ещё и перемещение элементов, чтобы заполнить дырку.
Схематично это можно представить так:
До:
индексы: 0 1 2 3
данные: [10] [20] [30] [40]
Удаляем элемент с индексом 1 (20)
После:
индексы: 0 1 2
данные: [10] [30] [40]
Элемент [30] переехал на место [20], [40] переехал на место [30]. Это важно: «на месте удалённого» оказывается другой элемент. Из-за этого любые привязки к элементам справа (итераторы, ссылки) становятся опасными: они либо смотрят не туда, либо вообще в «никуда».
erase(pos): удаляем один элемент по итератору
std::vector удаляет элементы через метод erase, и главное здесь — он принимает итератор, а не индекс. Это логично: итератор — это «позиция» в контейнере, а erase умеет удалять по позиции.
«Удалить по индексу» тоже можно, но тогда вы сначала превращаете индекс в итератор (или доходите до нужной позиции через ++), и только потом вызываете erase.
Мини‑пример: удалим второй элемент (20), не используя арифметику итераторов, а только ++.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30, 40};
auto it = v.begin();
++it; // теперь it указывает на 20
v.erase(it); // удаляем 20
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << ' '; // 10 30 40
}
std::cout << '\n';
}
erase(it) не «зануляет» место, а перестраивает последовательность. После удаления следующий элемент (30) занимает место удалённого.
erase(first, last): удаляем диапазон [first, last)
Иногда нужно удалить не один элемент, а целый кусок: например, «удалить элементы со 2-го по 4-й». В vector это делается erase(first, last), и тут есть важнейшее правило, которое вам будет встречаться ещё много раз в C++: диапазоны задаются как [first, last), то есть «левый включаем, правый не включаем».
last — это позиция после последнего удаляемого.
Удалим из [1, 2, 3, 4, 5] числа 2, 3, 4, снова соберём итераторы через ++:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3, 4, 5};
auto first = v.begin();
++first; // на 2
auto last = first;
++last; // на 3
++last; // на 4
++last; // на 5 (то есть "после 4")
v.erase(first, last); // удаляем 2,3,4
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << ' '; // 1 5
}
std::cout << '\n';
}
Полезная формулировка для самопроверки: last должен указывать на элемент после последнего удаляемого. В примере последний удаляемый — 4, значит last должен указывать на 5.
Что возвращает erase, и почему это спасает циклы
Если бы erase просто удалял и ничего не возвращал, удаление во время обхода превращалось бы в игру «угадай, куда теперь смотрит итератор». Поэтому erase возвращает итератор: позицию на элемент, который оказался на месте удалённого (или end(), если удалили последний элемент).
Мини‑пример: удалим 6 и продолжим работать с итератором, который вернул erase.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 6, 7, 8};
auto it = v.begin();
++it; // it на 6
it = v.erase(it); // удалили 6, it теперь на 7
if (it != v.end()) {
std::cout << *it << '\n'; // 7
}
}
Ключевая привычка: после erase не доверяем старому итератору, а берём тот, который вернула функция.
Удаление в цикле: плохой и хороший вариант
Когда вы впервые пишете «удалить все элементы, равные X», рука сама выводит что-то вроде: «иду по вектору, если встретил X — erase». И это тот случай, когда код может даже «иногда работать», что особенно опасно.
Плохой сценарий обычно выглядит так: итератор увеличивается в заголовке цикла, а внутри мы ещё и делаем erase, который ломает текущую позицию.
#include <vector>
int main() {
std::vector<int> v{1, 2, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
if (*it == 2) {
v.erase(it); // после этого it уже небезопасен
}
}
}
Правильная идея такая: если мы удалили текущий элемент, то «следующая позиция» уже выдана результатом erase. А если не удалили — тогда спокойно делаем ++it.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 2, 3};
for (auto it = v.begin(); it != v.end(); ) {
if (*it == 2) {
it = v.erase(it); // берём возвращённую позицию
} else {
++it; // двигаемся сами
}
}
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << v[i] << ' '; // 1 3
}
std::cout << '\n';
}
Обратите внимание: в заголовке цикла нет ++it. Это не «хитрый трюк», а дисциплина: теперь только один участок кода отвечает за перемещение итератора, и вы не получите «двойной шаг» или шаг по уже несуществующей позиции.
Инвалидация: что именно становится недействительным после erase
Под инвалидацией мы понимаем ситуацию, когда итератор/ссылка/указатель больше не гарантирует корректный доступ к тому элементу, на который он «смотрел». Использовать такой объект — это как читать адрес на конверте после того, как дом «переехал» в другой город.
Для std::vector удобна такая памятка:
| Операция с vector | Что может сломаться | Интуиция |
|---|---|---|
| erase(pos) / erase(first,last) | Итераторы/ссылки/указатели на удалённый элемент и элементы правее | элементы сдвигаются влево |
| push_back(...) (когда нет места и нужен «переезд») | Все итераторы/ссылки/указатели на элементы | буфер меняется, элементы оказываются по другим адресам |
| reserve(...) (если увеличил capacity и случился «переезд») | Все итераторы/ссылки/указатели | тот же «переезд» |
Про «переезд» вектора мы говорили ранее, когда обсуждали capacity() и перевыделение памяти. Здесь фиксируем следствие: если вектор поменял внутренний буфер, то любая «привязка к элементу» (итератор, ссылка) становится опасной.
Небольшая ремарка из жизни C++: формулировки стандартных правил про erase и инвалидацию действительно исторически обсуждались и уточнялись. То есть если вам сегодня кажется, что erase «скользкий», это нормально — тема реально тонкая.
Ссылка на элемент: почему T& опасна при изменении vector
Когда вы пишете int& ref = v[1];, вы создаёте ссылку — второе имя для конкретного элемента вектора. На вид это почти как обычная переменная, но у неё есть важное отличие: она живёт не сама по себе, а указывает в память, принадлежащую вектору. И если вектор перестроился (удаление/«переезд»), ссылка может стать недействительной.
Пример «выглядит безобидно, но на самом деле опасно»:
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
int& ref = v[1]; // ref ссылается на "20"
v.erase(v.begin()); // удалили 10, элементы сдвинулись
// ref теперь может ссылаться "не туда".
}
Здесь важно не пытаться угадать «что будет конкретно»: в учебной модели достаточно понять, что после изменения структуры вектора старые ссылки/итераторы нельзя использовать.
И ещё более жизненный сценарий: вы взяли ссылку, потом сделали push_back, а vector решил переехать в новый буфер (потому что capacity() кончилась). Тогда ссылке конец почти гарантирован.
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
int& ref = v[1]; // "20"
v.push_back(40); // может случиться перевыделение
// ref может стать недействительным
}
Да, это неприятно. Зато теперь понятно, почему в реальных проектах часто советуют не держать ссылки на элементы vector дольше, чем нужно.
Мини‑таск‑лист: удаляем задачу по индексу
Сейчас у нас ещё нет классов и сложной архитектуры, поэтому сделаем учебный «мини‑таск‑лист»: std::vector<std::string> tasks, где каждая строка — одна задача. Добавим удаление по номеру (индексу), но аккуратно: сначала проверим границы, потом найдём итератор и вызовем erase.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"Buy milk", "Wash cat", "Submit lab"};
std::size_t idx = 0;
std::cin >> idx;
if (idx < tasks.size()) {
auto it = tasks.begin();
for (std::size_t k = 0; k < idx; ++k) ++it;
tasks.erase(it);
}
for (std::size_t i = 0; i < tasks.size(); ++i) {
std::cout << i << ": " << tasks[i] << '\n';
}
}
Тут спрятаны две важные привычки. Во-первых, мы проверяем idx < tasks.size(), потому что индекс приходит «снаружи» (из ввода), а значит, он не заслужил доверия. Во-вторых, мы не пытаемся удалять по индексу напрямую, а честно переводим индекс в итератор, потому что erase работает с позициями.
Если вы сейчас подумали «эх, хочется tasks.begin() + idx», то поздравляю: ваш мозг уже нащупал идею random-access итераторов. Но в этой части курса мы держимся в пределах того, что уже ввели: begin()/end() и ++.
2. Типичные ошибки при удалении из std::vector
Ошибка №1: использовать итератор после erase, как будто ничего не произошло.
erase меняет структуру вектора и делает текущую позицию «скользкой». Типичный симптом: программа иногда пропускает элементы, иногда падает, иногда ведёт себя странно. Лечится дисциплиной: после it = v.erase(it) используем именно возвращённый итератор и не делаем ++it автоматически «по привычке».
Ошибка №2: перепутать диапазон [first, last) и ожидать, что last тоже удалится.
Это классика: вы хотите удалить «со 2 по 4 включительно», строите last на элемент 4, а потом удивляетесь, что 4 остался. Правильная модель не про «включительно», а про «последний не включаем»: last должен указывать на позицию после последнего удаляемого элемента.
Ошибка №3: держать ссылку (T&) на элемент, а потом менять vector.
Ссылка выглядит как удобная переменная, но она привязана к внутренней памяти контейнера. После erase, push_back (при перевыделении) или даже reserve (если произошёл «переезд») такая ссылка может стать недействительной. Если вам нужно «просто запомнить значение», берите копию (например, int x = v[i];). Если нужно «помнить позицию», помните, что позиция может сломаться при модификациях.
Ошибка №4: пытаться удалять в цикле for (...; ++it) и внутри делать erase(it).
Здесь ломается логика шага: erase уже перестроил последовательность, а вы ещё и увеличиваете итератор по старой схеме. В лучшем случае вы пропустите проверку для элемента, который сдвинулся на удалённую позицию; в худшем — получите обращение к невалидной позиции. Надёжный паттерн: цикл без автоматического ++it, где шаг делается либо ++it, либо it = erase(it).
Ошибка №5: думать, что инвалидация бывает только после erase.
Даже если сегодня мы фокусируемся на удалении, важно помнить связь с темой capacity(): если вектор переехал из‑за роста буфера, то ломаются вообще все итераторы/ссылки. Поэтому логика вида «сохранили итератор, потом много push_back, потом вернулись к итератору» — это заявка на приключение, которое вы не заказывали.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ