1. Индексные циклы: когда без них не обойтись
Индексный цикл легко полюбить за простоту: есть i, есть v[i], всё понятно. Но реальность подкидывает задачи, где без индекса вы как без рук: нужно напечатать номер элемента, обратиться к соседям i-1 и i+1, пройтись по контейнеру в обратном порядке или изменить конкретную позицию по номеру, который ввёл пользователь. Вот тут range-for становится слишком «безымянным»: он даёт элемент, но не говорит, какой он по счёту.
Чтобы не выбирать «люблю только range-for» или «люблю только индексы», полезно держать в голове простую мысль: индексный цикл — это не конкурент range-for, а инструмент для ситуаций, где важны позиции.
Небольшая «карта выбора» (схематично):
flowchart TD
A[Нужно пройти по контейнеру] --> B{Нужен номер элемента i?}
B -- нет --> C[range-for: проще и безопаснее]
B -- да --> D{Нужны соседи i-1 / i+1
или обратный порядок?}
D -- да --> E[Индексный цикл]
D -- нет --> F[Можно и индексный, и итераторный
но индексный читается проще]
std::size_t: что это за тип и почему он «мешает»
std::size_t — это целочисленный тип, предназначенный для размеров и индексов. В стандартной библиотеке размеры контейнеров (size()) возвращаются именно в типе std::size_t, и эта идея «размер — это size_t» встречается в стандарте очень широко, включая контекст размеров и границ (например, там, где речь идёт о величинах, связанных с индексами и размерами).
Главная «психологическая» проблема size_t для новичка: он беззнаковый. То есть отрицательных значений у него нет. И из этого вылезают две ловушки: во-первых, сравнения int и size_t могут вести себя неожиданно (и компилятор будет ворчать предупреждениями), во-вторых, попытка сделать i-1, когда i == 0, не даёт -1, а превращается в огромное число.
Посмотрим на мини-демонстрацию (да, это тот случай, когда математика делает вид, что она модульная):
#include <cstddef>
#include <iostream>
int main() {
std::size_t n = 0;
std::cout << (n - 1) << '\n'; // напечатает очень большое число
}
Смысл: n - 1 «перепрыгивает» через ноль, потому что тип не умеет быть отрицательным. Это не «баг компилятора», это нормальная арифметика беззнаковых типов.
2. Прямой обход и соседи: границы цикла решают всё
Базовый безопасный шаблон: i < v.size()
Когда вы пишете индексный цикл, мозг обычно думает так: «начинаем с 0, идём до размера». И здесь легко ошибиться ровно на одну итерацию — классическая ошибка off-by-one. Поэтому полезно зафиксировать правильный шаблон как «заклинание»:
for (std::size_t i = 0; i < v.size(); ++i) {
// работаем с v[i]
}
Почему именно <, а не <=? Потому что size() — это количество элементов, а индексы идут от 0 до size()-1. То есть size() — это «позиция сразу после последнего элемента». Если написать i <= v.size(), последняя итерация будет с i == v.size(), и тогда v[i] вылезет за границы.
Мини-проверка на простом примере (печатаем элементы с номерами):
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
for (std::size_t i = 0; i < v.size(); ++i) {
std::cout << i << ": " << v[i] << '\n';
}
// 0: 10
// 1: 20
// 2: 30
}
Обратите внимание: мы согласовали тип индекса с тем, что возвращает size(). Это сразу убирает целый класс предупреждений и «странных сравнений».
Соседи i+1 и i-1: без проверки границ нельзя
Как только в задаче появляется «сравнить текущий элемент со следующим» или «посчитать разницу между соседними», индекс становится удобным — и тут же начинает требовать дисциплины: надо следить, чтобы i+1 и i-1 существовали.
Очень типичная задача: пройти по парам соседей v[i] и v[i+1]. Здесь правильная граница цикла меняется: мы должны остановиться раньше, чтобы i+1 не вылез за пределы.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 8, 2, 10};
for (std::size_t i = 0; i + 1 < v.size(); ++i) {
int diff = v[i + 1] - v[i];
std::cout << diff << ' ';
}
std::cout << '\n';
// 3 -6 8
}
Почему условие именно i + 1 < v.size()? Потому что на последнем допустимом i мы хотим, чтобы i+1 был равен v.size()-1. Если i станет v.size()-1, то i+1 станет v.size(), и мы улетим за границы.
Теперь «опасный брат-близнец» этой задачи: использовать i-1. Если i — std::size_t, то при i == 0 выражение i-1 не станет -1, а превратится в огромное число. Поэтому такие проходы обычно начинают с i = 1.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{5, 8, 2, 10};
for (std::size_t i = 1; i < v.size(); ++i) {
int diff = v[i] - v[i - 1];
std::cout << diff << ' ';
}
std::cout << '\n';
// 3 -6 8
}
Здесь «страховка» встроена в старт: i = 1, значит i-1 всегда корректен.
3. Самые коварные места: size()-1, обратный цикл и смешивание типов
Самая коварная строка: v.size()-1
Эта тема заслуживает отдельного «медленного вдоха». Строка v.size()-1 выглядит невинно и даже логично: «последний индекс». И она правда логична… пока v не пустой. А когда v пустой, v.size() равно 0, а дальше у нас беззнаковая арифметика: 0 - 1 превращается в гигантское число.
Поэтому правило простое и довольно строгое: если вы собираетесь писать size()-1, вы обязаны перед этим проверить empty() (или size() > 0).
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v; // пустой
if (!v.empty()) {
std::size_t last = v.size() - 1;
std::cout << v[last] << '\n';
} else {
std::cout << "vector is empty\n"; // vector is empty
}
}
Заметьте, мы не пытаемся «аккуратно» вычислить last до if: это как сначала прыгнуть с парашютом, а потом вспомнить, что его надо было надеть.
Обратный обход и ловушка i >= 0
Когда вы впервые пытаетесь пройти по контейнеру с конца к началу, рука сама пишет что-то вроде:
// НЕ НАДО так делать
for (std::size_t i = v.size() - 1; i >= 0; --i) { ... }
И это один из самых популярных способов получить либо бесконечный цикл, либо доступ за пределы. Причина опять же в беззнаковости: i >= 0 для std::size_t всегда истинно, ведь i никогда не станет отрицательным.
Нормальный безопасный шаблон обратного обхода для std::size_t выглядит необычно, но он надёжен:
for (std::size_t i = v.size(); i-- > 0; ) {
// используем v[i]
}
Разберём человеческим языком: мы начинаем с i = v.size() (то есть «за последним»), потом в условии i-- > 0 сначала сравниваем старое значение i с нулём, а затем уменьшаем i. В итоге первая реальная итерация будет с i = v.size()-1, а последняя — с i = 0.
Демонстрация:
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
for (std::size_t i = v.size(); i-- > 0; ) {
std::cout << v[i] << ' ';
}
std::cout << '\n';
// 30 20 10
}
И приятный бонус: если v пустой (v.size() == 0), цикл просто не выполнится ни разу, и это корректно.
Почему компилятор ворчит на int и std::size_t, и он прав
Очень многие по привычке пишут индекс как int:
for (int i = 0; i < v.size(); ++i) { ... }
Иногда оно «работает», и это особенно опасно: вы начинаете доверять коду. Но проблема в том, что v.size() возвращает std::size_t, и сравнение int и size_t — это сравнение знакового и беззнакового типов. Компилятор может предупреждать, а в некоторых ситуациях логика реально ломается.
Представьте, что где-то в коде i стал отрицательным (например, вычислили i = something - 1). Сравнение i < v.size() может внезапно стать странным, потому что отрицательный int при приведении к size_t превращается в огромное положительное число.
Поэтому практическое правило для новичка такое: если вы сравниваете с v.size(), то и индекс берите std::size_t. А int оставляйте для вещей, которые по смыслу могут быть отрицательными (например, «не найдено: -1» — хотя позже мы будем решать это более аккуратно).
Иногда вам всё же нужно число как int (например, вы хотите вывести индекс как обычное целое или принять индекс от пользователя как int). Тогда лучше делать преобразование явно, чтобы вы сами видели этот момент. На нашем уровне достаточно запомнить static_cast.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{7, 8, 9};
int i = 1;
if (i >= 0 && static_cast<std::size_t>(i) < v.size()) {
std::cout << v[static_cast<std::size_t>(i)] << '\n'; // 8
}
}
Здесь мы сделали две вещи: проверили, что i не отрицательный, и только потом превратили его в size_t для сравнения и индексации.
4. Мини-приложение: «Трекер расходов» и зачем ему индексы
В предыдущих частях курса мы начали простое консольное приложение: трекер расходов за день. Мы храним суммы расходов в std::vector<int> expenses;, умеем добавлять новые значения, а сегодня хотим сделать две вещи, для которых индекс крайне удобен: печатать расходы с номерами и уметь обратиться к «конкретному расходу по его номеру».
Печать расходов с номерами
Когда вы печатаете список, пользователю обычно нужно видеть не только значения, но и «номер строки». range-for сам по себе номера не даёт, поэтому используем индексный цикл.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Expenses:\n";
for (std::size_t i = 0; i < expenses.size(); ++i) {
std::cout << " #" << i << ": " << expenses[i] << '\n';
}
// Expenses:
// #0: 120
// #1: 350
// #2: 80
}
Пока всё просто: главное — i < expenses.size().
Найти самый большой расход и его индекс
Частый паттерн: нам нужен не только максимум, но и «где он был». Это один из главных аргументов в пользу индексов: через индекс вы легко «запоминаете позицию».
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
if (expenses.empty()) {
std::cout << "No expenses\n";
return 0;
}
std::size_t best_i = 0;
for (std::size_t i = 1; i < expenses.size(); ++i) {
if (expenses[i] > expenses[best_i]) {
best_i = i;
}
}
std::cout << "Max expense: #" << best_i
<< " = " << expenses[best_i] << '\n';
// Max expense: #1 = 350
}
Обратите внимание на два момента. Мы проверили empty(), потому что иначе best_i = 0 и expenses[0] были бы просто обращением «в никуда». И мы начали цикл с i = 1, потому что нулевой элемент уже считается текущим максимумом.
Разница между соседними расходами
Иногда полезно видеть «скачки»: например, насколько следующий расход больше/меньше предыдущего. Это задача именно «по соседям», и индексный цикл здесь самый прямой.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Deltas:\n";
for (std::size_t i = 0; i + 1 < expenses.size(); ++i) {
std::cout << (expenses[i + 1] - expenses[i]) << '\n';
}
// Deltas:
// 230
// -270
}
Условие i + 1 < expenses.size() — это не «красивость», а защита от выхода за границу.
Печать в обратном порядке
В трекере расходов логично показывать последние записи первыми. Для этого нам нужен обратный обход, и здесь мы применяем «магическую» форму i-- > 0.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
std::cout << "Newest first:\n";
for (std::size_t i = expenses.size(); i-- > 0; ) {
std::cout << expenses[i] << '\n';
}
// Newest first:
// 80
// 350
// 120
}
Если вы запомните только один безопасный шаблон обратного цикла для size_t, пусть это будет он.
«Пользователь выбрал номер» → проверить границы
Индекс часто приходит извне: пользователь ввёл «удали расход номер 5» (удаление мы пока не трогаем), «покажи расход номер 1» или «замени расход номер 2». Даже если пока у нас нет функций и сложной обработки ввода, уже сейчас полезно привыкнуть проверять границы.
#include <cstddef>
#include <iostream>
#include <vector>
int main() {
std::vector<int> expenses{120, 350, 80};
int user_index = 1; // представим, что ввели из std::cin
if (user_index < 0) {
std::cout << "Index must be >= 0\n";
return 0;
}
std::size_t i = static_cast<std::size_t>(user_index);
if (i >= expenses.size()) {
std::cout << "Index out of range\n";
return 0;
}
std::cout << "expenses[" << i << "]=" << expenses[i] << '\n';
// expenses[1]=350
}
Да, кода чуть больше. Зато он не превращает неправильный ввод пользователя в «угадай, почему программа упала».
5. Шпаргалка: безопасные шаблоны индексных циклов
Иногда хочется просто «быстро подсмотреть» и не изобретать цикл заново. Вот компактная шпаргалка:
| Задача | Шаблон | Почему так |
|---|---|---|
| Прямой обход 0..n-1 | |
i < n никогда не даёт i == n |
| По соседям i и i+1 | |
гарантирует, что i+1 существует |
| По соседям i-1 и i | |
гарантирует, что i-1 существует |
| Обратный обход n-1..0 | |
работает и для n==0, без i>=0 |
6. Типичные ошибки при работе с индексами и std::size_t
Ошибка №1: условие i <= v.size() вместо i < v.size().
Эта ошибка выглядит очень «по-человечески»: «пока i не дошёл до размера, включительно». Но размер — это количество элементов, а не последний индекс. На итерации i == v.size() доступ v[i] уже выходит за границы.
Ошибка №2: попытка обратного цикла через i >= 0 при std::size_t.
На беззнаковом типе i >= 0 всегда истинно, потому что отрицательных значений нет. В результате цикл либо становится бесконечным, либо уходит в очень большие значения после декремента. Безопасный шаблон обратного обхода для size_t — for (std::size_t i = n; i-- > 0; ).
Ошибка №3: вычисление v.size()-1 без проверки !v.empty().
Если контейнер пустой, size() равен нулю, а 0 - 1 на беззнаковом типе превращается в огромное число. Проблема может проявиться не сразу: иногда вы используете это значение в условии, и получаете странное поведение, а не мгновенное падение.
Ошибка №4: доступ к соседям без корректной границы цикла.
Код v[i + 1] внутри цикла for (i = 0; i < v.size(); ++i) выглядит логично, пока не наступает последняя итерация. На ней i + 1 становится равен v.size(), и это уже «за последним». Для соседей нужен цикл с условием i + 1 < v.size() или аккуратная логика с проверками.
Ошибка №5: смешивание int и std::size_t без понимания, что происходит.
Когда вы сравниваете int i и v.size() (типа size_t), компилятор может делать неявные преобразования, и отрицательные числа внезапно превращаются в огромные положительные. Это особенно неприятно в проверках границ «по вводу пользователя»: кажется, что проверка есть, но она работает наоборот.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ