Итератор в C++: что это, begin и end, категории и примеры

Итератор в C++: что это, begin и end, категории и примеры Полезное

Итератор (iterator) в C++ — это объект, который указывает на элемент контейнера и умеет переходить к следующему. Через итератор читают и меняют элемент (*it), сдвигаются (++it) и сравнивают позицию с концом (it != v.end()). Именно через пары итераторов с контейнерами работают алгоритмы STL: std::sort, std::find, std::accumulate.

Ниже — полный рабочий пример, категории итераторов, std::next и std::advance, инвалидация (главный источник ошибок) и собственный итератор. Примеры проверены в Apple clang 21 (libc++) и GCC 14.2 (libstdc++) с -std=c++20.

Мини-словарь: что с чем путают

  • Итератор — объект-«позиция» в последовательности. У std::vector это класс, который ведет себя как указатель, но указателем быть не обязан.
  • Указатель — адрес в памяти. Указатель на элемент массива — частный случай итератора: к нему применимы *, ++, !=.
  • Индекс — номер элемента. Работает только там, где есть доступ по номеру (vector, array, string), а у list, set, map индексов нет, итераторы есть.
  • end() — позиция за последним элементом, а не сам последний элемент. Разыменовывать end() нельзя: это неопределенное поведение (UB).

Полный пример: begin, end и range-based for

#include <iostream>
#include <vector>

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

    // 1. Явный итератор: от begin() до end()
    for (std::vector<int>::iterator it = v.begin(); it != v.end(); ++it) {
        *it += 1;                  // разыменование дает доступ к элементу
    }

    // 2. То же самое через range-based for (C++11)
    for (int x : v) {
        std::cout << x << ' ';
    }
    std::cout << '\n';

    // 3. Расстояние между итераторами и end()
    auto it = v.begin() + 1;       // итератор на второй элемент
    std::cout << *it << ' ' << (v.end() - v.begin()) << '\n';
}

Вывод:

11 21 31 
21 3

Разбор. Тип итератора записывается как контейнер<тип>::iterator, на практике чаще пишут auto. Цикл идет, пока it != v.end(): сравнение именно с end(), а не <, потому что < есть не у всех итераторов. Range-based for for (int x : v) компилятор разворачивает в тот же цикл с begin()/end(), поэтому он работает с любым типом, у которого они есть. Разность v.end() - v.begin() дает число элементов, но только у итераторов произвольного доступа.

Если элемент нужно изменить в range-based for, берите ссылку: for (int& x : v) x += 1;. С int x меняется копия.

Категории итераторов: что умеет каждый

Возможности итератора зависят от контейнера. Стандарт делит итераторы на категории, каждая следующая добавляет операции к предыдущей (кроме выходного, он стоит отдельно).

Категория Операции сверх предыдущей Где встречается
Входной (input) *it на чтение, ++, ==/!=, один проход std::istream_iterator
Выходной (output) *it = x на запись, ++, один проход std::ostream_iterator, std::back_inserter
Однонаправленный (forward) многократный проход по тем же элементам std::forward_list, unordered_map, unordered_set
Двунаправленный (bidirectional) --it std::list, std::set, std::map
Произвольного доступа (random access) it + n, it - n, it[n], <, разность итераторов std::deque
Непрерывный (contiguous, C++20) элементы лежат в памяти подряд std::vector, std::array, std::string, обычный массив

Практический смысл таблицы: алгоритм требует минимальную категорию. std::sort нужны итераторы произвольного доступа, поэтому std::sort(l.begin(), l.end()) для std::list не скомпилируется — у списка есть свой метод l.sort().

std::next, std::advance, std::distance

Частая ошибка — сдвигать итератор списка через +:

#include <list>

int main() {
    std::list<int> l{1, 2, 3, 4};
    auto it = l.begin() + 2;   // ошибка: у list нет operator+
    return *it;
}

Компиляция останавливается, первая строка ошибки в clang 21 (libc++) и в GCC 14.2 (libstdc++):

ex2bad.cpp:5:25: error: invalid operands to binary expression ('iterator' (aka '__list_iterator<int, void *>') and 'int')
ex2bad.cpp:5:25: error: no match for 'operator+' (operand types are 'std::__cxx11::list<int>::iterator' and 'int')

Текст зависит от компилятора и библиотеки, но суть одна: оператора + у двунаправленного итератора нет. Исправление — функции из <iterator>, которые работают с любой категорией:

#include <iostream>
#include <iterator>
#include <list>
#include <vector>

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

    auto li = std::next(l.begin(), 2);   // list: 2 шага ++, O(n)
    auto vi = std::next(v.begin(), 2);   // vector: сразу +2, O(1)
    std::cout << *li << ' ' << *vi << '\n';

    std::advance(li, -1);                // двунаправленный: можно назад
    std::cout << *li << ' ' << *std::prev(l.end()) << '\n';

    std::cout << std::distance(l.begin(), l.end()) << '\n';
}

Вывод:

3 3
2 4
4
Функция Что делает Меняет итератор?
std::next(it, n) возвращает копию, сдвинутую на n нет
std::prev(it, n) копия, сдвинутая назад (нужен bidirectional) нет
std::advance(it, n) сдвигает сам it да
std::distance(a, b) число шагов от a до b нет

Граница: эти функции не проверяют выход за end(). Сдвиг дальше конца — UB, поэтому число шагов нужно сверять с размером контейнера самому.

Инвалидация: когда итератор становится недействительным

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

Неверный код — добавление в вектор во время обхода:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};
    v.shrink_to_fit();                     // просим capacity == size: push_back перевыделит память
    for (auto it = v.begin(); it != v.end(); ++it) {
        if (*it == 2) {
            v.push_back(20);               // итератор it становится недействительным
        }
        std::cout << *it << '\n';
    }
}

Без диагностики такой код обычно не падает, а печатает мусор: в прогоне clang 21 вывод был 1 0 2 0 1 2 3 20 20 20, в GCC 14.2 — сотни чисел из освобожденной памяти. Конкретный симптом зависит от компилятора и запуска, это UB. С AddressSanitizer (clang++ -g -fsanitize=address) видно, что происходит на самом деле: после 1 программа останавливается с ошибкой

==82396==ERROR: AddressSanitizer: heap-use-after-free on address 0x6020000000d4 ...

push_back перевыделил память, старый буфер освобожден, а it указывает в него. Исправление при удалении элементов — брать итератор, который возвращает erase, или в C++20 — std::erase_if:

#include <iostream>
#include <vector>

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

    // Удаление в цикле: erase возвращает итератор на следующий элемент
    for (auto it = v.begin(); it != v.end(); ) {
        if (*it % 2 == 0) {
            it = v.erase(it);              // не делаем ++it после erase
        } else {
            ++it;
        }
    }
    for (int x : v) std::cout << x << ' ';
    std::cout << '\n';

    // C++20: то же одной строкой, возвращает число удаленных
    std::vector<int> w{1, 2, 3, 4, 5, 6};
    auto removed = std::erase_if(w, [](int x) { return x % 2 == 0; });
    std::cout << removed << ' ' << w.size() << '\n';
}

Вывод: 1 3 5 и 3 3. Цикл с erase для вектора квадратичный в худшем случае (каждый erase сдвигает хвост), std::erase_if проходит за один раз — на больших данных выбирайте его. Для добавления при обходе надежнее собрать новые элементы в отдельный вектор и дописать после цикла.

Упрощенная сводка правил (подробные случаи, например для deque, — в документации конкретного контейнера):

Контейнер Вставка Удаление
vector, string при перевыделении памяти — все итераторы; иначе от точки вставки и дальше от удаленного элемента и дальше
list, set, map не инвалидирует только итераторы на удаленные элементы
unordered_map, unordered_set при rehash — все итераторы (ссылки на элементы остаются) только на удаленные

Итераторы в Си-семействе: C, C++, C# и Java

Запрос «iterator c» часто задают про чистый C. В языке C отдельных итераторов нет, их роль играет указатель, и идея begin/end оттуда же:

#include <stdio.h>

int main(void) {
    int a[] = {3, 1, 4};
    const int *end = a + sizeof a / sizeof a[0];   /* адрес "за последним" */
    for (const int *p = a; p != end; ++p) {
        printf("%d ", *p);
    }
    printf("\n");
    return 0;
}

Программа печатает 3 1 4. Указатель на позицию сразу за массивом стандарт C разрешает вычислять и сравнивать, но не разыменовывать — ровно как end() в C++.

Язык Что играет роль итератора Шаг и доступ Конец
C указатель ++p, *p указатель за последним элементом
C++ container::iterator ++it, *it end()
C# IEnumerator<T> MoveNext(), Current MoveNext() вернул false
Java Iterator<T> hasNext(), next() hasNext() вернул false

В C# и Java итератор сам знает, где конец, а в C++ конец — отдельный объект, и пара begin/end задает диапазон. Как это устроено в C#, разобрано в статье про цикл foreach в C#.

Обратные и константные итераторы, поиск

#include <iostream>
#include <map>
#include <string>
#include <vector>

int main() {
    const std::vector<int> v{1, 2, 3};
    for (auto it = v.rbegin(); it != v.rend(); ++it) std::cout << *it << ' ';
    std::cout << '\n';

    std::map<std::string, int> ages{{"Ann", 30}, {"Bob", 25}};
    auto found = ages.find("Bob");
    if (found != ages.end()) {                     // end() = "не найдено"
        std::cout << found->first << '=' << found->second << '\n';
    }
    auto cit = v.cbegin();
    // *cit = 5;  // не скомпилируется: const_iterator только для чтения
    std::cout << *cit << '\n';
}

Вывод: 3 2 1, Bob=25, 1. rbegin()/rend() дают обход с конца, cbegin() — константный итератор, запись через него — ошибка компиляции (clang: cannot assign to return value because function 'operator*' returns a const value, GCC: assignment of read-only location). find возвращает end(), если ключа нет, поэтому результат сравнивают с end() до разыменования. Доступ к полям элемента — через ->, как у указателя.

Свой итератор: чтобы работали range-for и алгоритмы

Чтобы класс работал в for (x : obj) и с алгоритмами STL, ему нужны begin()/end(), а итератору — *, ++ и ==. Пример — диапазон целых чисел, который ничего не хранит, а вычисляет значения на лету:

#include <cstddef>
#include <iostream>
#include <iterator>
#include <numeric>

// Диапазон целых чисел [first, last) с собственным итератором
class IntRange {
public:
    class iterator {
    public:
        using iterator_concept  = std::forward_iterator_tag; // для C++20-концептов
        using iterator_category = std::input_iterator_tag;   // для старых алгоритмов, как у views::iota
        using value_type        = int;
        using difference_type   = std::ptrdiff_t;
        using reference         = int;          // значение вычисляется, не хранится

        iterator() = default;
        explicit iterator(int v) : cur_(v) {}

        int operator*() const { return cur_; }
        iterator& operator++() { ++cur_; return *this; }       // ++it
        iterator operator++(int) { auto t = *this; ++cur_; return t; } // it++
        bool operator==(const iterator&) const = default;

    private:
        int cur_ = 0;
    };

    IntRange(int first, int last) : first_(first), last_(last < first ? first : last) {}
    iterator begin() const { return iterator(first_); }
    iterator end() const { return iterator(last_); }

private:
    int first_, last_;
};

static_assert(std::forward_iterator<IntRange::iterator>);  // проверка концепта C++20

int main() {
    IntRange r(1, 6);
    for (int x : r) std::cout << x << ' ';                  // range-based for
    std::cout << '\n';
    std::cout << std::accumulate(r.begin(), r.end(), 0) << '\n';  // алгоритм STL
    IntRange empty(5, 1);                                   // last < first -> пустой диапазон
    std::cout << std::distance(empty.begin(), empty.end()) << '\n';
}

Вывод: 1 2 3 4 5, затем 15 и 0. Конструктор защищает от last < first: без этого begin() никогда не догнал бы end(), и цикл ушел бы в бесконечность с переполнением int. static_assert с концептом std::forward_iterator проверяет интерфейс итератора при компиляции. Два тега категории нужны потому, что operator* возвращает значение, а не ссылку: по правилам C++17 такой итератор формально только входной, для концептов C++20 — однонаправленный. Похожий прием у std::views::iota: для int у него iterator_category тоже входной, а iterator_concept — произвольного доступа (там реализованы еще --, += и <).

Выводы

  • Итератор в C++ — объект-позиция: *it дает элемент, ++it сдвигает, конец диапазона — end(), который стоит за последним элементом.
  • Возможности зависят от категории: it + n и std::sort есть только у итераторов произвольного доступа, для list и map сдвигайте через std::next/std::advance.
  • Изменение контейнера во время обхода может инвалидировать итераторы; удаляйте через it = erase(it) или std::erase_if.
  • В C роль итератора играет указатель, в C# и Java — объекты с MoveNext/hasNext, но идея «позиция + шаг + признак конца» общая.
  • Свой итератор делает класс совместимым с range-based for и алгоритмами STL; концепты C++20 проверяют его при компиляции.

Где применяется / связь с практикой

Освойте тему на практике

Итераторы — основа работы с STL: через них пишутся обходы контейнеров, вызовы алгоритмов, поиск в map и set, собственные коллекции. На собеседованиях по C++ регулярно спрашивают про категории итераторов и инвалидацию. Контейнеры, итераторы и алгоритмы системно разбирают на курсе «C++-разработчик. Базовый уровень», а попробовать формат можно на бесплатных открытых уроках Otus.

FAQ

Чем iterator отличается от const_iterator?
Через const_iterator элемент можно только читать. Его возвращают cbegin()/cend() и begin() у константного контейнера.

Можно ли сравнивать итераторы разных контейнеров?
Нет, сравнение итераторов из разных контейнеров — UB, даже если типы совпадают. Сравнивают только итераторы одного диапазона.

Нужно ли подключать <iterator>, чтобы пользоваться begin() и end()?
Для методов контейнера — нет, они объявлены в заголовке контейнера. <iterator> нужен для std::next, std::advance, std::distance, std::back_inserter и концептов вроде std::forward_iterator.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)