JavaRush /Курсы /C++ SELF /Углубляемся в литераторы

Углубляемся в литераторы

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

1. Введение

Когда вы только начинаете программировать, кажется логичным: «если у меня есть std::vector<int> v, то пусть функция принимает v и делает что надо». И это правда… до тех пор, пока вы не захотите обработать не весь контейнер, а кусок: первые 10 элементов, элементы со 2-го по 5-й, или всё кроме первого. Вот тут и появляется магия (на самом деле математика) диапазона.

В стандартной библиотеке C++ почти все алгоритмы думают так: «мне не важно, что это за контейнер; покажи мне, где начало, где конец, и я пройду по элементам». Это делает алгоритмы универсальными: один и тот же std::find может работать с vector, string, array и даже с «обычным» массивом.

Чтобы так жить, библиотеке нужен общий язык. Этот язык — итераторы и пара функций begin() / end().

Итератор: указатель с правилами

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

Мини‑пример: пройдёмся по vector итераторами и напечатаем элементы. Здесь важно увидеть три операции: «взять начало», «остановиться на конце», «разыменовать текущий элемент».

#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 != v.end() — это не «красивость», а правило безопасности. end() — это граница, а не элемент.

2. Диапазон [first, last): включаем начало, исключаем конец

Сейчас мы подойдём к самой важной идее сегодняшней лекции. В STL принято задавать диапазон не как «с первого по последний включительно», а как [first, last): first включён, last не включён. Сначала это выглядит как странная традиция программистов, но потом оказывается, что это супер‑удобно.

Почему это удобно? Потому что «пустой диапазон» можно записать одинаковыми итераторами: first == last. Потому что длина диапазона естественно считается как «сколько шагов от first до last». И потому что «весь контейнер» — это просто begin()end() без специальных случаев.

Нарисуем простую схему диапазона для вектора из трёх элементов:

flowchart LR
    A["begin()"] --> B["элемент 0"]
    B --> C["элемент 1"]
    C --> D["элемент 2"]
    D --> E["end() (за последним)"]

end() стоит после последнего элемента, как «клетка за финишной чертой».

Мини‑пример: пройти не по всему vector, а только по «хвосту», начиная со второго элемента. Мы возьмём begin(), сделаем ++it один раз и будем идти до end().

#include <iostream>
#include <vector>

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

    auto it = v.begin();
    ++it; // теперь указывает на 20

    for (; it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 20 30
}

Это и есть сила диапазона: вы можете «подвинуть начало», но конец остаётся тем же.

3. Границы диапазона и константные итераторы

Почему end() нельзя разыменовывать

В программировании есть желания, которые лучше не исполнять. Разыменовать end() — одно из них. end() не указывает на элемент, он указывает «за последним». Это как адрес «следующего после последнего стула» в аудитории: стула там нет, но «позиция» в пространстве существует.

Если вы напишете *v.end(), вы нарушите контракт итераторов. В лучшем случае получите странные данные, в худшем — падение, в самом весёлом — «работает на моём компьютере».

Правильный подход простой: прежде чем разыменовывать итератор, мы убеждаемся, что он не равен end().

#include <iostream>
#include <vector>

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

    auto it = v.end();
    if (it == v.end()) {
        std::cout << "it is end(), no element here\n";
    }
}

Этот паттерн «сначала проверить — потом использовать» пригодится ещё много раз, особенно когда мы начнём получать итераторы как результат работы алгоритмов.

begin()/end() и cbegin()/cend(): только чтение

У контейнеров есть begin()/end(), а ещё часто есть cbegin()/cend(). Буква c означает const: то есть «я обещаю, что не буду менять элементы».

Это полезно сразу по двум причинам. Во‑первых, компилятор реально помогает: если вы случайно попытаетесь изменить элемент, он не даст. Во‑вторых, такой код легче читать: вы открываете функцию и сразу понимаете, что она контейнер не портит (по крайней мере, через итератор).

Мини‑пример: печатаем vector, но подчёркиваем, что чтение «только чтение».

#include <iostream>
#include <vector>

int main() {
    const std::vector<int> v{4, 5, 6};

    for (auto it = v.cbegin(); it != v.cend(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 4 5 6
}

Если бы мы попробовали сделать *it = 42;, компилятор бы нас остановил. Это приятный момент: компилятор — ваш строгий преподаватель, который не даёт списывать.

4. Алгоритмы и итератор результата: пример std::find

«Весь контейнер» как диапазон

Зафиксируем главную мысль лекции: когда STL‑алгоритм говорит «дай мне данные», он обычно ожидает два итератора: начало и конец диапазона. И «весь контейнер» просто подставляется как c.begin(), c.end() (или cbegin()/cend()).

Эта форма настолько стандартная, что вы будете видеть её постоянно:

algo(c.begin(), c.end(), ...);

или, если мы не хотим менять контейнер и хотим подчеркнуть «чтение»:

algo(c.cbegin(), c.cend(), ...);

std::find: пробник интерфейса

Один алгоритм уже можно показать как «пробник интерфейса» — std::find. Он ищет значение в диапазоне и возвращает итератор: либо «где нашли», либо last (то есть обычно end()), если не нашли.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 7, 9};

    auto it = std::find(v.begin(), v.end(), 7);
    if (it != v.end()) {
        std::cout << "found: " << *it << '\n'; // found: 7
    } else {
        std::cout << "not found\n";
    }
}

Ключевой момент: результат find нельзя разыменовывать «вслепую». Он может оказаться равным end().

Итератор результата: «позиция найденного» или «маркер не найдено»

Многие алгоритмы в STL возвращают не «индекс», не true/false, а именно итератор результата. Это удобно, потому что итератор — это сразу и «позиция», и возможность что-то сделать с элементом (прочитать или изменить), если это разрешено контрактом.

Смысл такого результата обычно читается так: «вот место, где найдено». А если не найдено — «место равно last».

Посмотрим на это на простом примере со строкой. Да, std::string тоже отдаёт итераторы, и да — алгоритмы работают со строкой почти так же, как с vector.

#include <algorithm>
#include <iostream>
#include <string>

int main() {
    std::string s = "cat";

    auto it = std::find(s.begin(), s.end(), 'a');
    if (it != s.end()) {
        std::cout << "letter: " << *it << '\n'; // letter: a
    }
}

Здесь it — это «указатель» на символ внутри строки. И если строка не константная, мы теоретически могли бы заменить найденную букву (например, сделать из "cat""cut"). Но мы сделаем это чуть позже, когда добавим нашу модель задач.

5. Мини‑проект: TaskTracker и поиск по id через итераторы

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

Начнём с модели Task. Здесь всё максимально приземлённо: id, текст и флаг done.

#include <string>

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

Теперь сделаем печать задач. Важно: мы принимаем const std::vector<Task>&, потому что печать не должна менять список.

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        std::cout << it->id << ". " << it->title << " done=" << it->done << '\n';
    }
}

Обратите внимание на it->id. Это удобная запись вместо (*it).id. Когда у вас итератор ведёт себя как указатель, -> становится очень приятным.

Поиск задачи по id: возвращаем итератор

Теперь сделаем функцию «найти задачу по id». И специально сделаем её в STL‑стиле: она возвращает итератор. Если не нашли — возвращаем tasks.end().

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

#include <vector>

std::vector<Task>::iterator find_task_by_id(std::vector<Task>& tasks, int id) {
    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.end();
}

Функция короткая, но она учит трём вещам сразу: «обход диапазона», «поиск» и «маркер не найдено». А главное — у неё интерфейс уже совместим с тем, как мы будем думать дальше: итератор как результат.

Использование: сначала проверяем, потом пользуемся

Покажем, как пользоваться этой функцией безопасно: проверить it != end(), и только потом менять задачу.

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Learn C++", false}, {2, "Sleep", false}};

    auto it = find_task_by_id(tasks, 2);
    if (it != tasks.end()) {
        it->done = true;
    }

    print_tasks(tasks);
    // 1. Learn C++ done=0
    // 2. Sleep done=1
}

И вот здесь становится очевидно, почему итератор результата иногда удобнее индекса. Мы нашли элемент и сразу поменяли его, не вычисляя «какой это индекс», не делая вторую итерацию, не держа лишние переменные.

Константный поиск: const_iterator и cend()

Иногда вам нужна версия поиска «только чтение». Тогда возвращаем const_iterator и используем cbegin()/cend().

#include <vector>

std::vector<Task>::const_iterator find_task_by_id(const std::vector<Task>& tasks, int id) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.cend();
}

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

6. Индекс и итератор: когда что выбирать

Многим новичкам хочется спросить: «а почему не индекс? он же проще». Индекс действительно проще в vector, но как только вы выходите за пределы vector, индекс перестаёт быть универсальным. У строки индекс есть, у массива индекс есть, но у многих структур данных индекса «по смыслу» нет, а итератор есть.

Чтобы закрепить картину, держите таблицу сравнения. Она не требует заучивания, но помогает мозгу перестать путать «позицию» и «итератор».

Что сравниваем Индекс Итератор
Работает ли одинаково для разных контейнеров Нет (зависит от контейнера) Да (это «универсальный интерфейс»)
Можно ли задать «кусок контейнера» Можно, но часто криво Естественно: [first, last)
Что возвращает «поиск» Обычно индекс или -1 Итератор или end()
Можно ли случайно выйти за границы Очень легко Легко, если разыменовать end(), но паттерн проверки спасает
Идея «маркер не найдено» -1 / size() / что-то ещё end() / last (единый стиль)

STL выбрала итераторы, потому что они позволяют строить универсальные алгоритмы. А begin()/end() — это способ подключить контейнер к этому миру.

7. Типичные ошибки при работе с begin()/end() и итераторами

Ошибка №1: разыменование end() «на авось».
Самая частая проблема выглядит так: «я вызвал find, получил it, сразу пишу *it». Если элемент не найден, it будет равен end(), а разыменование end() — это выход за контракт. Лечится одной дисциплиной: сначала if (it != end()), потом доступ к элементу.

Ошибка №2: сравнение итераторов от разных контейнеров.
Иногда по невнимательности пишут что-то вроде if (it == other.end()). Итераторы можно сравнивать только в рамках одного контейнера (или одного диапазона). В лучшем случае код не скомпилируется, в худшем — вы получите очень странную логику. Держите в голове правило: «итератор знает, откуда он».

Ошибка №3: путаница «итератор — это индекс».
Итератор — не число. Его нельзя «просто распечатать как позицию», нельзя прибавлять к нему что угодно (в общем случае), и нельзя ожидать, что он ведёт себя как int. Если вам нужна именно позиция — это отдельный шаг логики, но в алгоритмическом стиле чаще не нужна: итератор уже достаточен, чтобы читать/менять элемент.

Ошибка №4: использование begin()/end() там, где вы обещали «только чтение».
Если функция должна только печатать или проверять, а вы берёте begin() (неконстантный итератор), то код становится менее защищённым: кто-то позже «случайно» добавит изменение элемента — и компилятор промолчит. Привычка писать cbegin()/cend() в функциях чтения делает код крепче, особенно в больших проектах.

Ошибка №5: попытка «лечить» всё const-ом без понимания.
Иногда студент видит ошибку, добавляет const куда попало, и оно «перестаёт компилироваться иначе». Если вы сделали контейнер const, то вы не сможете вызывать алгоритмы/функции, которые хотят менять порядок или значения. Это не «каприз компилятора», это защита контракта. Прежде чем добавлять const, спросите себя: «я реально хочу запретить изменения — или я просто пытаюсь заткнуть ошибку?»

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