JavaRush /Курсы /C++ SELF /Итерация по std::vector

Итерация по std::vector: индексы vs range-for

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

1. Три способа итерации

Когда вы впервые видите три разных способа «пройтись по вектору», возникает честный вопрос: «Почему нельзя было оставить один нормальный цикл, и всем было бы спокойно?» Вопрос отличный. Реальность такая: разные задачи требуют разной “точки зрения” на коллекцию — иногда нам нужен номер элемента, иногда нужен только сам элемент, а иногда нужна «позиция» в контейнере (итератор), которая потом используется для других операций.

Давайте сразу зафиксируем, что мы сегодня сравниваем:

  • Индексный цикл: мы ходим по i = 0..size()-1 и обращаемся к v[i].
  • range-for: мы пишем for (auto x : v) и получаем элементы без ручных индексов.
  • Итераторы: мы пишем for (auto it = v.begin(); it != v.end(); ++it) и работаем через *it.

Все три способа — не конкуренты «кто лучше», а скорее разные инструменты. Как отвёртка, ключ и молоток: технически молотком можно закрутить винт… но обычно после этого винт и настроение немного страдают.

Индексный цикл: когда нужен номер элемента

Индексный цикл — самый прямолинейный подход, и он обычно первым приходит в голову. Мы явно создаём переменную i, задаём границы и на каждой итерации берём элемент v[i]. Это похоже на «перелистывание страниц в книге», где у каждой страницы есть номер — и вам иногда действительно нужен именно номер страницы, а не только текст на ней.

Самая важная мысль здесь: цикл должен идти по size(), а не по capacity(), и условие почти всегда выглядит как i < v.size(). Почему не <=? Потому что последний допустимый индекс — size() - 1, а size() — уже «за последним элементом».

Базовый индексный обход

#include <cstddef>
#include <iostream>
#include <vector>

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

    for (std::size_t i = 0; i < scores.size(); ++i) {
        std::cout << "scores[" << i << "]=" << scores[i] << '\n';
    }
}
// scores[0]=10
// scores[1]=20
// scores[2]=30

Здесь всё прозрачно: есть индекс, есть элемент. Это удобно, когда вы печатаете «номер → значение», строите табличку, или хотите изменить элемент по позиции.

Когда индексный цикл особенно полезен

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

range-for: читаемость и нюанс с копиями

range-for (range-based for) обычно любят за то, что он делает цикл визуально короче: вы говорите «для каждого элемента вектора сделай …». Это ближе к человеческому языку и, если честно, к нашей внутренней мечте: «компьютер, сделай красиво, я устал писать i < size()».

Но у range-for есть нюанс, из-за которого новички часто ловят странное поведение: вы можете случайно работать с копией элемента, а не с самим элементом вектора. То есть вы вроде как меняете x, а вектор остаётся прежним — потому что вы меняете временную копию.

Небольшая историческая ремарка: даже детали семантики range-for когда-то уточнялись и правились на уровне стандартных формулировок.

range-for только читать

#include <iostream>
#include <vector>

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

    for (int x : scores) {              // x — копия каждого элемента
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 10 20 30

Здесь копия — не проблема, потому что мы только читаем.

Почему «изменение» может не сработать

#include <iostream>
#include <vector>

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

    for (int x : scores) {
        x += 5;                         // меняем копию, не вектор
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 10 20 30

Кажется, что мы «прибавили 5», но нет: мы прибавили 5 к копиям. Вектор не изменился.

range-for менять элементы: нужен &

#include <iostream>
#include <vector>

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

    for (int& x : scores) {             // x — ссылка на элемент
        x += 5;                         // меняем сам вектор
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 15 25 35

Вот здесь уже меняется сам контейнер, потому что x — это «доступ к оригиналу». В нашем курсе мы пока не уходили глубоко в теорию ссылок как отдельную тему, поэтому запомните упрощённо: & в range-for означает «работаю с настоящим элементом, а не с копией».

Итераторы begin()/end(): «позиция» в контейнере

Итераторы часто пугают названием. Оно звучит так, как будто сейчас начнётся лекция уровня «квантовая механика контейнеров», и вы случайно поступили не на C++-курс, а на факультет боли. На деле итератор в базовой версии — это просто позиция в контейнере, очень похожая на «умный указатель» (но без погружения в указатели).

Главная мысль:

  • begin() — позиция на первом элементе (если он есть),
  • end() — позиция после последнего элемента.

И вот это «после последнего» — ключевое. end() не указывает на реальный элемент, поэтому разыменовывать *v.end() нельзя. Это как «дверь выхода» из коридора: она нужна, чтобы понять, что коридор закончился, но жить в ней нельзя.

В стандарте C++ тема итераторов и их требований настолько важна, что в редакторских отчётах отдельно встречаются правки и уточнения разделов про итераторы.

Базовый цикл по итераторам

#include <iostream>
#include <vector>

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

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        std::cout << *it << ' ';        // *it — текущий элемент
    }
    std::cout << '\n';
}
// 10 20 30

Здесь auto — не “магия”, а просто способ не писать длинный тип итератора руками. На вашем уровне это воспринимается как «компилятор, пожалуйста, сам догадайся».

Изменение элементов через итератор

#include <iostream>
#include <vector>

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

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        *it += 1;                       // меняем элемент "на месте"
    }

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}
// 11 21 31

Если вы не меняете структуру вектора (не вставляете/удаляете элементы), то такой подход работает предсказуемо. А вот что будет при удалении — это как раз тема следующей лекции (там появится erase и разговор про инвалидирование).

3. Как выбрать подход и не превратить код в ребус

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

Критерий Индексы (i) range-for Итераторы (begin()/end())
Читаемость «просто пройтись и вывести» Средняя Отличная Средняя
Нужен индекс (позиция элемента) Отлично Неудобно (нужен отдельный счётчик) Неудобно (нужен счётчик)
Хотим менять элементы Да Да, но важно & Да
Риск ошибки границ Есть (off-by-one) Минимальный Средний (end() нельзя разыменовывать)
Подготовка к операциям вроде удаления/вставки Слабее Слабее Сильнее (обычно erase принимает итератор)

Чтобы это закрепить в голове, можно представить небольшой «маршрутизатор выбора» в виде схемы:

flowchart TD
    A[Нужно пройтись по vector] --> B{Нужен индекс?}
    B -->|Да| C["Индексный цикл for (size_t i...)"]
    B -->|Нет| D{Нужно менять элементы?}
    D -->|Нет| E["range-for: for (auto x : v)"]
    D -->|Да| F{Нужно потом работать с позицией?}
    F -->|Нет| G["range-for: for (auto& x : v)"]
    F -->|Да| H["Итераторы: for (auto it = begin; it != end; ++it)"]

Схема не идеальна, но хорошо отражает практику: для «прочитать» почти всегда выигрывает range-for, для «прочитать + индекс» — индексы, а для «позиции в контейнере» — итераторы.

4. Практический пример: журнал оценок и три обхода

Сейчас соберём маленький пример, который ощущается как настоящее приложение, а не как «три строчки ради трёх строчек». Пусть у нас будет простая программа: пользователь вводит несколько оценок, мы их сохраняем в std::vector<int>, затем печатаем, добавляем бонусные баллы и ищем первую «провальную» оценку.

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

Считываем оценки в вектор

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

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores;
    int x = 0;

    while (std::cin >> x) {
        scores.push_back(x);
    }

    std::cout << "count=" << scores.size() << '\n';
}
// ввод: 10 20 30
// вывод: count=3

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

Печать с индексами

Вступление: когда вы показываете пользователю список значений, часто нужно не только значение, но и его номер. Это как список задач или список покупок: «0 — молоко, 1 — хлеб». Тут индексный цикл ощущается наиболее естественным: он буквально делает то, что мы хотим — даёт номер и доступ к элементу.

#include <cstddef>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};

    for (std::size_t i = 0; i < scores.size(); ++i) {
        std::cout << i << ": " << scores[i] << '\n';
    }
}
// 0: 50
// 1: 75
// 2: 90

Посчитать сумму

Вступление: суммирование — типичный случай, где индекс вообще не важен. Нам не нужно знать «какой это по счёту элемент», нам нужно просто взять каждый и добавить к сумме. И именно в таких задачах range-for даёт максимальную читабельность: меньше деталей — меньше шансов ошибиться.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};
    int sum = 0;

    for (int x : scores) {
        sum += x;
    }

    std::cout << "sum=" << sum << '\n';
}
// sum=215

Добавить бонус ко всем

Вступление: следующий жизненный сценарий — «подкрутить данные» массово. Например, преподаватель решил, что контрольная была сложной, и всем добавляем +5. Здесь range-for всё ещё очень хорош, но важно не забыть про &, иначе вы будете «подкручивать копии» и удивляться, почему справедливость не наступает.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};

    for (int& x : scores) {
        x += 5;
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 55 80 95

Найти первую оценку ниже порога

Вступление: поиск первого «плохого» элемента — хороший пример, где итераторы выглядят естественно. Мы идём от begin() к end(), проверяем *it, и как только нашли — останавливаемся. Это похоже на то, как вы листаете список, пока не наткнётесь на нужное. Плюс такой стиль морально готовит нас к операциям, которые работают именно с позициями.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{80, 55, 90, 40};
    const int limit = 60;

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        if (*it < limit) {
            std::cout << "first failing score=" << *it << '\n';
            break;
        }
    }
}
// first failing score=55

Если бы нам ещё понадобилась позиция, мы могли бы вести отдельный счётчик pos, но принцип останется тем же.

5. Типичные ошибки при итерации по std::vector

Ошибка №1: условие i <= v.size() вместо i < v.size().
Это классическая off-by-one проблема: вы случайно делаете лишнюю итерацию, где i == v.size(). А это уже индекс «за последним элементом». Иногда программа падает сразу, иногда ведёт себя странно — и это как раз тот тип багов, которые сложно искать, если не следить за границами дисциплинированно.

Ошибка №2: использовать int для индекса и сравнивать с v.size().
size() возвращает std::size_t (беззнаковый тип). Если вы пишете int i и сравниваете с v.size(), можно получить неприятные неявные преобразования, особенно когда i может стать отрицательным или когда в коде появляется больше арифметики вокруг индекса. На этом этапе курса лучше считать правилом: индексы и размеры — это std::size_t.

Ошибка №3: разыменование end() (то есть попытка сделать *v.end()).
end() — это не последний элемент, а позиция «после последнего». Разыменовывать её нельзя. Это не “почти работает”, это “почти работает только в фильмах про хакеров”, а в реальности — ошибка.

Ошибка №4: «почему не изменилось?» — range-for по значению вместо ссылки.
Когда вы пишете for (int x : v), x — копия. Меняете x — меняете копию. Чтобы менять элементы вектора, нужно for (int& x : v). Это один из самых частых сюрпризов у новичков, и он особенно коварен тем, что код выглядит логично.

Ошибка №5: менять структуру вектора во время обхода и думать, что “ничего страшного”.
Если вы внутри цикла начинаете делать push_back, erase и подобные операции, могут ломаться «позиции» и доступы к элементам. Сегодня мы это трогаем только как предупреждение: пока вы учитесь обходу — обходите. А вот безопасные способы удаления и что именно «ломается» — это отдельная тема.

1
Задача
C++ SELF, 12 уровень, 4 лекция
Недоступна
Печать каталога
Печать каталога
1
Задача
C++ SELF, 12 уровень, 4 лекция
Недоступна
Массовый бонус
Массовый бонус
1
Задача
C++ SELF, 12 уровень, 4 лекция
Недоступна
Первый тревожный
Первый тревожный
1
Задача
C++ SELF, 12 уровень, 4 лекция
Недоступна
Сдвиг к нулю
Сдвиг к нулю
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ