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, спросите себя: «я реально хочу запретить изменения — или я просто пытаюсь заткнуть ошибку?»
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ