В этом разделе нам еще раз придется поговорить о массивах и об указателях: мы взглянем на всё немного под другим углом и это расширит понимание указателей, как полезного инструмента, что приблизит нас к понимаю итераторов.

Массивы как указатели

На самом деле массивы в C - это указатели на первый элемент массива. Например:

1
2
3
4
int a[5] = {1, 2, 3, 4, 5};
int* p = a; // указатель на первый элемент массива, ровно то же самое, что и a
cout << (p == a) << '\n'; // true
cout << *p << '\n'; // 1

Когда вы передаете массив в функцию, вы передаете указатель на первый элемент массива. Например:

1
2
3
4
5
6
7
8
9
10
void print_array(int* a, int n) {
    for (int i = 0; i < n; ++i) {
        cout << a[i] << " ";
    }
}

int main() {
    int a[5] = {1, 2, 3, 4, 5};
    print_array(a, 5);
}

Но если мы знаем указатель на первый элемент - адрес в памяти первого элемента - то мы можем получить и адрес любого другого элемента массива, массив в памяти сохранет под ряд. Это естественным образом приводит нас к арифметике указателей.

Арифметика указателей

Оказывается, к указателям можно прибавлять и вычитать целые числа - это экивалетно сдвигу в индексах массива. Еще одна важная операция - разность двух указателей - эквивалетно расстоянию между индексами в массиве. Например:

1
2
3
4
5
6
int a[5] = {1, 2, 3, 4, 5};
int* p = a;
int* q = p + 2; // указатель на третий элемент массива
cout << *p << endl; // 1
cout << *q << endl; // 3
cout << q - p << endl; // 2 - разница в индексах

Эти операции крайне просты, если мы думаем над ними в терминах адресов в памяти и звучат крайне логично.

Итераторы

Универсальные операции

Прежде чем говорить о итераторах, давайте поговорим, что такое контейнеры. Контейнеры - это классы, которые хранят данные. Например, string - хранит массив символов, vector - хранит массив элементов любого типа, set - это отсортированное множество элементов любого типа и другие. Подробнее о каждом из контейнеров вы узнаете в следующем разделе.

На данно этапе важно понимать, что контейнеры - это абстракция, которая позволяет скрыть детали реализации. Например, как хранятся данные в векторе, как добавляются элементы в множество и так далее. Однако, независимо от типа контейнера, нам нужно уметь однаково работать с элементами любого контейнера, например банально вывести все элементы на экран.

C++ благодаря полиморфизму позволяет нам написать универсальные функции, которые работают с элементами любого контейнера, независимо от того, как устроены эти самые контейнеры. Итераторы - это и есть инструмент, который позволяет нам универсально работать с элементами контейнера.

Как же устроен “универсальный инструмент”?

Попытаемся понять, что же нам нужно для того, чтобы вывести все элементы контейнера на экран, используя новый уровень абстракции. Пока будем считать что итератор - это указатель на элемент контейнера, тогда:

Нам нужно уметь:

  • Получить итератор на первый элемент контейнера.
  • Получить следующий элемент контейнера.
  • Понять, что мы дошли до конца контейнера.
  • Разыменовать итератор, чтобы получить значение элемента контейнера.

Оказывается, любой стандартный контейнер в C++ поддерживает каждую из этих операций:

1
2
3
for (auto it = s.begin(); it != s.end(); ++it) {
    cout << *it << " ";
}

Разберемся с каждой операцией:

  • begin() - возвращает итератор на первый элемент контейнера.
  • end() - возвращает итератор на элемент, следующий за последним элементом контейнера. Обратите внимание, что end() не указывает на последний элемент контейнера.
  • ++it - сдвигает итератор на следующий элемент.
  • *it - разыменовывает итератор.

Обратите внимение на то, что в C++ begin и end обозначают ПОЛУИНТЕРВАЛ. Это часто вызывает путаницу у начинающих, поэтому будьте внимательны. В то же время данный подход позволяет писать более красивый код, например размер строки можно вычислить как end() - begin() без лишних +-1.

В C++11 появились удобные циклы для работы с контейнерами, назывваемые range-for, которые полностью эквивалентны предыдущему циклу (буквально раскроются в предыдущий цикл):

1
2
3
for (auto value : s) {
    cout << value << " ";
}

Давайте разберем другой пример - операция lower_bound - это операция которая находит итератор на первый элемент, который больше или равен заданному в отсортированном контейнере. Например:

1
2
3
4
int key = 6;
vector<int> a = {1, 3, 5, 7, 9};
auto it = lower_bound(a.begin(), a.end(), key);
cout << *it << endl; // 7

Реализуем операцию с помощью бинарного поиска:

1
2
3
4
5
6
7
8
9
10
auto l = a.begin(), r = a.end(); // [l, r)

while (r - l > 1) {
    auto m = l + (r - l) / 2;
    if (*m < key) {
        l = m;
    } else {
        r = m;
    }
}

Оказывается, этот пример фундаментально отличается от предыдущего, потому что мы потребовали от итератора большего - поддерживать арифметику указателей (уметь прибавлять к итератору число и узнавать разность между двумя итераторами).

Почему итератор - это НЕ указатель?

Теперь мы готовы ответить на главный вопрос этой мини-лекции про итераторы: почему итератор - это НЕ указатель?

Погодите, я буквально двумя абзацами выше сказал, что итератор - это указатель на элемент контейнера. Но это не совсем так, всё дело во внетреннем устройстве контейнеров:

  • Какие-то контейнеры не хранят элементы в памяти последовательно (например, deque), поэтому “сдвинуть указатель на 10 элементов вперёд” может быть не так просто.
  • Какие-то контейнеры - это деревья (set, map), поэтому “сдвинуть указатель на 10 элементов вперёд” вообще невозможно эффективно без хранения дополнительной информации.

Поэтому у итераторов разных контейнеров реализованы разные подмножества операций. В зависимости от того, какое подмножество операций реализовано, итераторы можно разделить на несколько категорий.

Категории итераторов

  • forward_iterator - итератор, который позволяет двигаться только вперед по контейнеру.
  • bidirectional_iterator - итератор, который позволяет двигаться в обе стороны по контейнеру.
  • random_access_iterator - итератор, который позволяет двигаться в обе стороны по контейнеру и совершать произвольные сдвиги, то есть он поддерживает арифметику указателей.
  • contiguous_iterator - всё вышеперечисленное и еще элементы контейнера хранятся в памяти последовательно.

Например, итераторы вектора или строки - это contiguous_iterator, а итераторы set - это bidirectional_iterator, поэтому у нас бы не получилось реализовать операцию lower_bound для set с помощью бинарного поиска.

Упражнения

В произаольном контейнере выведите последний элемент.

Решение
1
cout << *(--s.end()) << endl;

В произвольном контейнере выведите все элементы в обратном порядке.

Решение

Обратите внимание, что без проверки на пустой контейнер код бы не работал корректно!

1
2
3
4
5
6
if (!s.empty()) {
    for (auto it = s.end(); it != s.begin(); ) {
        --it;
        cout << *it << " ";
    }
}