1. Зачем нужны итераторы и как они устроены
Иногда кажется: «Я же могу написать for (const auto& x : v) и всё, жизнь удалась». И часто это правда. Но бывает, что нам нужно больше контроля: например, пройти не весь контейнер, а только часть; остановиться на найденном элементе; аккуратно менять элементы по месту; или просто научиться читать STL-код, где циклы часто пишутся через итераторы. Итератор — это как “режим ручного управления” для обхода: чуть больше букв, но и больше возможностей.
Представьте, что range-for — это поезд, который едет от станции «первый элемент» до станции «последний элемент» по расписанию. А итераторы — это ваш личный велосипед: вы сами выбираете скорость, остановки и маршрут. Да, иногда вы забудете шлем (то есть проверку end()), но мы учимся.
Итератор как «обобщённый указатель»: идея без магии
Слово «итератор» звучит так, будто его придумали, чтобы пугать новичков (и иногда это правда). Но идея простая: итератор — это объект, который указывает на позицию в контейнере. Его часто сравнивают с указателем, потому что у него есть похожие операции: можно сдвинуться на следующий элемент (++it), можно получить текущий элемент (*it), можно сравнить с другой позицией (it != end).
Почему «обобщённый»? Потому что указатель работает по сути только с непрерывной памятью (как массив), а итератор — это единый интерфейс для разных контейнеров. std::vector хранит элементы подряд, std::array тоже, а вообще в STL есть контейнеры, где всё сложнее. Но сегодня мы остаёмся в рамках vector/array и берём от итераторов самое практичное.
Небольшая аналогия: указатель — это адрес дома, итератор — это “навигатор”, который умеет говорить «вот текущий дом» и «перейти к следующему». В некоторых районах (контейнерах) «следующий дом» вычисляется просто, в других — хитрее, но для нас интерфейс одинаковый.
begin() и end(): «первый» и «за последним»
Чтобы итераторы были полезны, контейнер должен уметь выдавать две ключевые позиции: начало и конец. В C++ это делается парой методов begin() и end().
Очень важно понять одну вещь (и дальше всё будет логично): end() — это не последний элемент. Это позиция после последнего элемента. То есть «граница», «стоп-линия», «дверь выхода», как угодно — но не элемент.
Вот схема, которую полезно держать в голове:
begin() -> [ elem0 ][ elem1 ][ elem2 ] -> end()
^ ^
можно * end() разыменовывать нельзя
Почему так сделано? Потому что тогда очень удобно писать цикл: «пока не дошли до end() — работаем». Если бы конец был “последним элементом”, то цикл постоянно путался бы с границами.
Мини-пример: просто покажем наглядно, что begin() и end() существуют, и мы можем идти от одного к другому.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
auto it = v.begin();
std::cout << *it << '\n'; // 10
}
Здесь it указывает на первый элемент, и *it читает этот элемент. Уже похоже на указатели, да?
Скелет итераторного цикла: «инициализация → условие → шаг»
Когда вы пишете цикл по индексу, вы явно создаёте счётчик i, сравниваете i < size(), и делаете ++i. В итераторном цикле роль “счётчика” играет итератор it, роль “границы” — end(), а роль “шага” — ++it.
Классический скелет выглядит так:
for (auto it = v.begin(); it != v.end(); ++it) {
// работа с *it
}
И да, это тот самый момент, когда auto начинает приносить реальную пользу: тип итератора длинный и вам пока не нужен в явном виде.
Мини-пример: печать всех элементов.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << ' ';
}
std::cout << '\n'; // 10 20 30
}
Обратите внимание на ++it (префиксный инкремент). Для обычного int разницы почти нет, но для итераторов в общем стиле C++ принято писать именно ++it: это понятный сигнал «мы просто передвигаемся вперёд», и во многих случаях это потенциально дешевле, чем it++.
Если хочется увидеть это как маленькую блок-схему, то вот:
flowchart TD
A["получить it = begin()"] --> B{"it != end()?"}
B -- нет --> E[выходим из цикла]
B -- да --> C[используем *it]
C --> D[++it]
D --> B
2. Доступ к элементам через итератор
Разыменование *it: чтение и изменение
Когда вы пишете *it, вы получаете текущий элемент. И вот тут важный нюанс: чаще всего *it — это не копия, а доступ к элементу контейнера. То есть в выражении *it живёт «настоящий элемент», и вы можете его менять (если контейнер не const и итератор не константный).
Сравним чтение и изменение на примере нашего простого учебного приложения: “ToDo Lite”, где задачи — это строки в std::vector<std::string>.
Сначала просто печать:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
std::cout << *it << '\n';
}
}
Теперь модификация: добавим к каждой задаче префикс "TODO: ".
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators"};
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
*it = "TODO: " + *it;
}
for (const auto& s : tasks) {
std::cout << s << '\n';
}
// TODO: buy milk
// TODO: learn iterators
}
Здесь *it слева от = означает: «замени элемент контейнера». То есть это прямое изменение вектора. И это как раз тот момент, где итераторы особенно полезны: вы явно работаете с “позициями” в контейнере.
Ещё один небольшой трюк для читаемости: иногда удобно сразу “вынуть” ссылку на элемент.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators"};
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
auto& task = *it; // task — ссылка на элемент
task += " [ok]"; // меняем элемент вектора
}
for (const auto& s : tasks) std::cout << s << '\n';
// buy milk [ok]
// learn iterators [ok]
}
Так вы буквально заставляете код «прочитаться»: task — это элемент, мы его правим.
cbegin()/cend() и const_iterator: режим «только чтение»
Иногда мы хотим обойти контейнер так, чтобы случайно ничего не изменить. Да, можно просто быть аккуратным. Но практика показывает, что “быть аккуратным” — это стратегия уровня “просто не ошибайся”. Поэтому в C++ есть способ зафиксировать намерение: обход только для чтения.
Для этого существуют cbegin() и cend() — они возвращают константные итераторы, через которые нельзя менять элементы. Поведение cbegin()/cend() отдельно оговаривается в стандартной библиотеке именно как более явный «const-режим» обхода.
Вот пример: посчитаем суммарную длину задач (чисто чтение).
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators"};
std::size_t total = 0;
for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
total += it->size(); // можно и так: (*it).size()
}
std::cout << total << '\n'; // 23
}
Здесь появился оператор ->. Он читается как «разыменуй и возьми поле/метод». То есть it->size() — это то же самое, что (*it).size(), но короче и часто читается легче.
Важно: если контейнер сам по себе const, то даже begin() вернёт “чтение-итератор”. Но cbegin() полезен тем, что вы пишете намерение прямо в коде: «я не собираюсь менять элементы». А намерения, как известно, компилятор читает лучше некоторых людей.
Давайте покажем, что модификация действительно запретится:
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk"};
for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
// *it = "oops"; // так нельзя: it — "только чтение"
}
}
Комментарии тут не просто для красоты: если раскомментировать, компилятор честно скажет «нельзя».
Небольшой исторический факт на уровне идеи: в стандартной библиотеке есть даже свободные функции std::cbegin/std::cend, которые появились не сразу — в обсуждениях стандарта отдельно поднимался вопрос про их необходимость. Это не то, что вам нужно запомнить, но полезно знать, что “const-обход” — это реальная, важная практика, а не прихоть преподавателя.
3. Как range-for связан с begin()/end()
range-for выглядит как магия, но в основе он завязан на ту же идею: есть начало и конец, и мы идём от begin к end. То есть range-for — это «сахар» (удобная форма записи), а итераторы — “сырьё”, из которого этот сахар сделан.
В супер-упрощённом виде можно думать так:
for (auto x : v) { ... }
примерно похоже на:
for (auto it = v.begin(); it != v.end(); ++it) {
auto x = *it;
...
}
Это не буквальная трансляция (там есть нюансы), но как учебная модель отлично работает: вы понимаете, почему range-for идёт слева направо, почему он не включает end(), и почему “начало/конец” — основа обхода контейнеров.
Проверим на практике тот же ToDo-вектор: сначала итераторы, потом range-for, результат один и тот же.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators"};
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
std::cout << *it << '\n';
}
for (const auto& t : tasks) {
std::cout << t << '\n';
}
}
Если вы видите в чужом коде итераторы — это не значит, что автор хотел вас наказать. Часто это просто значит: ему нужно было немного больше контроля, чем даёт range-for.
4. Практика: нумерация и поиск
Когда мы пишем учебное приложение, оно обычно начинает расти в сторону «мне нужно чуть-чуть логики вокруг списка». Для ToDo-листа типично хочется печатать задачи с номерами и уметь находить первую задачу, которая содержит слово. Это как раз ситуации, где итераторы помогают: они позволяют остановиться ровно там, где нужно, и сохранить позицию.
Печать задач с нумерацией
Номер элемента не хранится в итераторе “по умолчанию”, поэтому просто заведём счётчик:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};
int index = 1;
for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
std::cout << index << ") " << *it << '\n';
++index;
}
// 1) buy milk
// 2) learn iterators
// 3) sleep
}
Мы используем cbegin()/cend(), потому что печать — это точно «только чтение», и нам приятно, когда компилятор тоже это знает.
Поиск первой задачи, содержащей подстроку
Да, в будущем мы научимся делать это красивее через алгоритмы, но сегодня — ручной цикл, чтобы почувствовать механику итераторов.
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};
std::string needle = "learn";
auto it = tasks.begin();
for (; it != tasks.end(); ++it) {
if (it->find(needle) != std::string::npos) {
break;
}
}
if (it != tasks.end()) {
std::cout << "Found: " << *it << '\n'; // Found: learn iterators
} else {
std::cout << "Not found\n";
}
}
Здесь важно сразу несколько вещей.
Во-первых, мы отдельно объявили it перед циклом — потому что хотим использовать его после цикла. Если объявить auto it = ... прямо внутри for, то после цикла итератор “умрёт” вместе со своей областью видимости, и мы не сможем проверить результат.
Во-вторых, мы проверяем it != tasks.end() после цикла. Это абсолютно ключевой паттерн: «если дошли до конца — не нашли; если остановились раньше — нашли». И он работает как раз потому, что end() — “за последним”, то есть однозначный маркер “ничего нет”.
5. Типичные ошибки при работе с итераторами
Ошибка №1: разыменование end() (и вообще «сначала *it, потом подумал»).
Самая частая проблема: человек пишет *it, не убедившись, что it действительно указывает на элемент. Если it == v.end(), то это позиция “за последним”, элемента там нет, и разыменование — ошибка. Привычка должна быть железной: если вы вышли из поиска циклом и хотите использовать итератор, сначала сравните с end().
Ошибка №2: ожидание, что end() — это последний элемент.
Это очень понятная ошибка, потому что слово “end” в голове звучит как «конец = последний». В STL это “конец диапазона”, а не “последний элемент”. Поэтому условие цикла пишется как it != end(), а не it <= end() и не попытки «включить end в обработку». Если помнить формулу “end() = за последним”, большинство граничных проблем исчезает.
Ошибка №3: использование cbegin()/cend(), а потом попытка изменить элемент.
Это даже не ошибка, а полезный “стоп-сигнал” от компилятора. Если вы выбрали cbegin() и вдруг захотели менять элементы — значит, вы сами себе противоречите: либо обход должен быть чтением, либо модификацией. Если реально нужна модификация, используйте begin()/end() (и делайте это осознанно), а если модификация случайная — радуйтесь, что cbegin() спас вас от бага.
Ошибка №4: забытый шаг ++it и «вечный день сурка».
Итераторный цикл — обычный for: ему нужен шаг. Если по ошибке убрать ++it, цикл зациклится (а вы будете смотреть на зависшую программу и думать, что это “оптимизация компилятора”). Внимательно проверяйте структуру: инициализация, условие, шаг.
Ошибка №5: смешивание итераторов из разных контейнеров (или разных векторов).
Сравнивать it от одного контейнера с end() от другого — это логически бессмысленно. Такое бывает, когда переменные называются одинаково (v, v2), а код копируется кусками. Держите итераторы “рядом” со своим контейнером и старайтесь не разносить их далеко по коду, пока не появятся функции (это будет позже).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ