1. Введение
Если вы уже познакомились с T* и с v.begin()/v.end(), возникает естественный вопрос: «Почему у нас два разных способа пройти по элементам? Нельзя было оставить один, чтобы мозг не перегревался?». Сравнение итераторов и указателей — это способ навести порядок в голове: понять, что у них общего, где они принципиально разные, и почему стандартная библиотека выбрала именно итераторы как основной «язык» для контейнеров и алгоритмов (у C++ даже есть отдельные разделы про итераторы в документах по стандарту).
Главная цель этой лекции — чтобы вы, глядя на код, сразу понимали: «ага, тут у нас доступ к памяти напрямую (указатель), а тут доступ через абстракцию контейнера (итератор)». И самое важное: вы перестанете ожидать от итератора поведения «как у указателя во всех ситуациях». Это ожидание — один из самых частых источников ошибок у новичков.
Напоминание: что такое указатель и что он обещает
Указатель в C++ — это значение, которое хранит адрес объекта в памяти. Это как записка «вот дом и квартира, где живёт переменная». Когда вы пишете *p, вы говорите: «перейди по этому адресу и возьми объект». Когда пишете p->field, вы говорите то же самое, только с удобным доступом к полю структуры/класса. И, да, указатель может быть nullptr, то есть «адреса нет», поэтому указатели часто используют как способ выразить опциональность в API.
Важно увидеть здесь контракт. Указатель сам по себе не гарантирует, что адрес валидный, что объект жив, что память принадлежит вам, и что по нему вообще можно безопасно читать/писать. Он даёт силу (прямой доступ к памяти), но почти не даёт страховки. Поэтому у указателей есть репутация «всё можно, но один неверный шаг — и вы в болоте».
Мини-пример, чтобы вспомнить базовую механику:
#include <iostream>
int main() {
int x = 10;
int* p = &x;
*p += 5;
std::cout << x << '\n'; // 15
}
Здесь p хранит адрес x, а *p — это доступ к тому же объекту. Никакой магии: объект один.
2. Напоминание: что такое итератор и что он обещает
Итератор — это объект, который позволяет «ходить» по элементам контейнера. По ощущениям он действительно напоминает указатель: его можно сдвигать (++it), сравнивать (it != end), разыменовывать (*it). Но ключевая идея другая: итератор не обязан быть «сырым адресом». Это ручка (handle) или маркер позиции внутри конкретного контейнера.
Если указатель говорит «я знаю адрес в памяти», то итератор говорит «я знаю, как добраться до текущего элемента контейнера, по правилам этого контейнера». Это важно, потому что контейнер может хранить элементы не так, как вы себе представляете. Даже если вы пока работали в основном с std::vector, стандартная библиотека в целом строится на том, что контейнеры бывают разные, и итераторы дают единый способ обхода.
С точки зрения практики, стандартная библиотека очень любит итераторы: большинство алгоритмов (std::find, std::sort, std::count_if и т.д.) принимает диапазон именно как [begin, end) — то есть пару итераторов.
Мини-пример с std::vector:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
auto it = v.begin();
std::cout << *it << '\n'; // 10
++it;
std::cout << *it << '\n'; // 20
}
Обратите внимание: синтаксис «как у указателя», но смысл — «позиция в контейнере».
4. Сходство: почему итератор часто выглядит как указатель
Сходство итераторов и указателей не случайно: итераторы в C++ задумывались как «обобщённые указатели». То есть такие сущности, которые позволяют обойти элементы структуры данных и получить доступ к элементу — но не обязательно через прямой адрес. Поэтому базовые операции у них очень похожи: разыменование, продвижение вперёд, сравнение на равенство/неравенство.
Полезная аналогия: указатель — это «координаты на карте», а итератор — это «навигатор, который знает маршрут внутри конкретного города». Навигатор тоже может показать «текущее место» и «следующую точку», но вы не обязаны знать, как именно устроены дороги. И если город вдруг перестроит развязку (контейнер изменится), навигатор может перестать быть актуальным — тут мы аккуратно подходим к теме валидности.
Ниже — маленькая табличка, чтобы глазами закрепить сходство синтаксиса:
| Действие | Указатель T* p | Итератор it |
|---|---|---|
| Получить элемент | |
|
| Доступ к полю | |
(если *it — структура/класс) |
| Перейти к следующему | (для массивов/непрерывной памяти) |
|
| Проверка окончания | |
|
И ещё один момент, который приятно удивляет новичков: обычный указатель на элемент массива может выступать итератором для многих алгоритмов. Это один из мостов между «миром указателей» и «миром STL».
Например:
#include <algorithm>
#include <iostream>
int main() {
int a[5]{3, 1, 4, 1, 5};
int* begin = a;
int* end = a + 5;
auto it = std::find(begin, end, 4);
std::cout << (it != end) << '\n'; // 1
}
Здесь int* ведёт себя как итератор: его можно передать в алгоритм как границы диапазона.
5. Отличия: итератор — это позиция в контейнере, а не адрес
Теперь самое полезное: отличия. Потому что сходство обычно видно сразу, а вот отличия «всплывают» в виде странных ошибок и вопросов «почему оно не компилируется, я же просто хотел it + 2?».
Итератор связан с контейнером и его правилами. Контейнер задаёт, что значит «следующий элемент», что значит «конец», и какие операции допустимы. Для std::vector итератор часто действительно реализован как почти-указатель (внутри может быть обычный T*), но это деталь реализации, а не обещание языка. Важно привыкнуть к мысли: итератор — это не «адрес», а «объект доступа», который может быть сложнее, чем кажется.
Ещё одно ключевое отличие — концепция end(). В стандартных контейнерах end() — это позиция «за последним элементом». Это не элемент. Это «стоп-линия». Её нельзя разыменовывать. И это очень похоже на указатель a + n в массиве: он валиден как маркер границы, но разыменование уже ошибка.
Хорошо работает такая схема диапазона:
flowchart LR
B[begin] --> E1[elem 0] --> E2[elem 1] --> E3[elem 2] --> END[end = за последним]
Мы обычно пишем цикл вида it != end, потому что end — не «последний элемент», а «после последнего».
Мини-пример типового правильного обхода:
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{1, 2, 3};
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << ' '; // 1 2 3
}
std::cout << '\n';
}
И важная мысль: если вы думаете «итератор — это указатель», вы можете начать ожидать от всех итераторов, что их можно, например, «прибавлять сдвиг» (it + 5). Но это далеко не всегда так. Даже если сейчас вы видите, что с vector это работает, не делайте из этого «универсальное правило языка». Пусть вашим правилом будет другое: я делаю с итератором только то, что явно похоже на корректный обход: ++, *, сравнение с end().
const: const_iterator и «константные» указатели
Тема const выглядит как «опять бюрократия», но на практике она очень честно показывает разницу между указателем и итератором.
С указателями у нас есть сразу несколько смыслов const, и это легко запутывает. Например, const int* p означает «через p нельзя менять int», а int* const p означает «сам p нельзя переназначить, но int менять можно».
У итераторов идея похожа, но выражается иначе: есть обычный итератор (условно «можно менять элементы») и const_iterator (условно «можно только читать элементы»). Когда контейнер сам const, то begin() возвращает «читающий» итератор, и попытка изменить элемент через *it = ... не компилируется. Это не «вредность компилятора», а встроенная защита от случайной модификации данных, которые объявлены константными.
Пример:
#include <iostream>
#include <vector>
int main() {
const std::vector<int> v{1, 2, 3};
auto it = v.begin(); // итератор чтения
const int& x = *it;
std::cout << x << '\n'; // 1
// *it = 10; // ошибка компиляции
}
Указатель тоже может обеспечить такое поведение, но итератор делает это «по умолчанию» через типы контейнера: контейнер сам решает, какой доступ честный.
6. Практика: TaskBook и поиск задачи
Чтобы тема не осталась «теорией про какие-то странные объекты», давайте продолжим наш учебный консольный проект TaskBook — простейший список задач на базе std::vector<std::string>. Мы не будем делать ничего «архитектурного» и большого: только маленькие функции, которые подсвечивают, чем итератор удобнее указателя в мире контейнеров.
Представим, что у нас уже есть список задач:
#include <string>
#include <vector>
std::vector<std::string> make_demo_tasks() {
return {"buy milk", "read cpp", "sleep"};
}
Поиск элемента: итераторный контракт [begin, end)
Когда вы ищете задачу в vector, естественный интерфейс в стиле STL — вернуть итератор на найденный элемент (или end(), если не нашли). Это очень «родной» подход: он сразу работает и с печатью, и с удалением, и с изменением найденного элемента.
#include <string>
#include <vector>
std::vector<std::string>::iterator find_task(
std::vector<std::string>& tasks,
const std::string& query
) {
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
if (*it == query) return it;
}
return tasks.end();
}
Здесь возвращается именно «позиция в контейнере». И это удобно: мы не привязаны к индексам и не вынуждены возиться с std::size_t.
Пример использования:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "read cpp", "sleep"};
auto it = find_task(tasks, "read cpp");
if (it != tasks.end()) {
*it += " (30 min)";
}
std::cout << tasks[1] << '\n'; // read cpp (30 min)
}
Обратите внимание на красоту: мы нашли — и прямо через итератор обновили элемент.
Похожий поиск через указатели: работает, но другой уровень абстракции
Теперь представим, что мы хотим сделать «то же самое», но через указатели. У vector есть метод data(), который возвращает T* на внутренний массив. Но тут мы сразу входим в мир допущений: мы привязываемся к тому, что данные лежат непрерывно. Для vector это верно, но для «контейнеров вообще» — не факт (и это одна из причин существования итераторов).
Пример «указательного» поиска:
#include <string>
#include <vector>
std::string* find_task_ptr(std::vector<std::string>& tasks, const std::string& q) {
if (tasks.empty()) return nullptr;
std::string* p = tasks.data();
for (std::size_t i = 0; i < tasks.size(); ++i) {
if (p[i] == q) return &p[i];
}
return nullptr;
}
А теперь использование:
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<std::string> tasks{"buy milk", "read cpp", "sleep"};
if (auto* p = find_task_ptr(tasks, "sleep")) {
*p = "sleep (8h)";
}
std::cout << tasks.back() << '\n'; // sleep (8h)
}
Это работает, но вы уже чувствуете разницу в «температуре» кода. Итераторная версия выглядит как «я работаю с контейнером». Указательная версия выглядит как «я залез в память контейнера». На раннем этапе обучения это может быть не критично, но по мере роста проекта такие различия начинают влиять на безопасность и переносимость решений.
Почему итератор легче подружить с алгоритмами
Самый практичный бонус итераторов: они напрямую «втыкаются» в STL-алгоритмы. И самое приятное — многие алгоритмы принимают диапазон именно итераторами, поэтому вы можете менять реализацию контейнера, а интерфейс обхода остаётся похожим.
Например, перепишем find_task через std::find:
#include <algorithm>
#include <string>
#include <vector>
std::vector<std::string>::iterator find_task2(
std::vector<std::string>& tasks,
const std::string& query
) {
return std::find(tasks.begin(), tasks.end(), query);
}
Код стал короче, а смысл прозрачнее: «найди в диапазоне».
7. Как выбирать: когда вам нужен указатель, а когда итератор
Если попытаться сформулировать «практическое правило без философии», то оно звучит так: итераторы — для обхода контейнеров, указатели — для прямого доступа к объекту (особенно когда объект может отсутствовать). На самом деле, конечно, оба инструмента пересекаются, и иногда указатель выступает итератором (как в массиве). Но в прикладном коде хорошая привычка — не смешивать уровни абстракции без причины.
Указатель хорош, когда вы проектируете функцию и хотите явно сказать: «параметр может быть отсутствующим» (nullptr). Это более честно, чем пытаться сделать «nullable-ссылку». Кроме того, указатель — это естественный мост к низкоуровневым API и к коду, который работает с памятью напрямую (но мы сегодня туда не идём).
Итератор хорош, когда вы мыслите «диапазоном» и хотите работать с контейнером без предположений о внутреннем устройстве. Итераторная пара [begin, end) — это почти стандартный «универсальный разъём» для обхода. Вы передали диапазон — и дальше с ним работают одинаково.
Небольшая «шпаргалка-психотерапия»:
| Ситуация | Что чаще выбрать | Почему |
|---|---|---|
| «Пройди по контейнеру» | Итераторы | Контейнер задаёт правила обхода |
| «Верни позицию найденного элемента» | Итератор | Удобно сравнить с end(), удобно удалять/менять |
| «Параметр может отсутствовать» | Указатель | Есть nullptr как честный сигнал |
| «Работаю с массивом/буфером» | Указатели (или begin/end) | Указатель естественен для непрерывной памяти |
И да: если у вас возникает желание «сделать из итератора адрес, потому что так проще» — это часто сигнал, что вы пытаетесь решать задачу не на том уровне. Лучше на секунду остановиться и спросить себя: «я сейчас про контейнер или про память?».
8. Типичные ошибки при сравнении итераторов и указателей
Ошибка №1: считать, что итератор — это всегда “просто указатель”.
На std::vector итераторы часто ведут себя очень похоже на указатели, и это расслабляет. Но итератор — это абстракция, а не обещание непрерывной памяти. Если вы привыкаете делать с итератором всё то же, что с T*, вы быстро попадёте в ситуацию, где «вроде логично», но «почему-то не компилируется». Правильная привычка — использовать минимальный набор операций: *, ++, сравнение с end().
Ошибка №2: разыменовывать end() или “после последнего”.
end() — это не элемент. Это граница диапазона. Разыменование *end() — логическая ошибка. Указатели имеют такую же ловушку: a + n можно держать как маркер, но нельзя *(a + n). Если хочется «последний элемент», то это обычно --end() (но осторожно) или методы контейнера вроде back() (если контейнер не пустой).
Ошибка №3: хранить итератор/указатель на элемент и забыть, что контейнер мог измениться.
Даже без подробной таблицы инвалидирования (она у вас будет позже), полезно помнить общий принцип: если контейнер меняет внутреннее устройство (например, увеличился и “переехал” в другое место), то старые «привязки» к элементам могут стать невалидными. Новички часто делают так: взяли it на элемент, потом где-то сделали push_back, а потом снова используют it и удивляются странностям. Здесь помогает дисциплина: либо не хранить такие привязки долго, либо очень чётко контролировать изменения контейнера.
Ошибка №4: путать “копию значения” и “ссылку на элемент” при разыменовании.
В выражениях вида auto x = *it вы получаете копию элемента, а auto& x = *it — ссылку на элемент. В первом случае изменения x не меняют контейнер, во втором — меняют. Та же идея есть и с указателями (int x = *p против int& x = *p). Ошибка здесь особенно обидная: программа компилируется и работает, но делает «не то», и вы потом полчаса спорите с монитором.
Ошибка №5: использовать указатели там, где хочется выразить “обход диапазона”, и наоборот.
Иногда студент пишет функцию «обойти вектор» и принимает T* и size, потому что «указатели уже знаю». Это работает, но вы теряете смысл контейнера и увеличиваете шанс ошибиться с границами. А иногда наоборот: пытаются через итератор выразить опциональность («может быть пусто»), хотя для этого честнее T*/nullptr. Хороший интерфейс обычно читается как предложение: если у вас обход — берите итераторы, если у вас «может отсутствовать» — берите указатель.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ