JavaRush /Курсы /C++ SELF /Итераторы: begin/end, cbegin/cend

Итераторы: begin/end, cbegin/cend

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

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), а код копируется кусками. Держите итераторы “рядом” со своим контейнером и старайтесь не разносить их далеко по коду, пока не появятся функции (это будет позже).

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