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 << "Вектор порожній\n"; // Вектор порожній
}
}
Зауважте: ми не намагаємося обчислити 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 << "Витрати:\n";
for (std::size_t i = 0; i < expenses.size(); ++i) {
std::cout << " #" << i << ": " << expenses[i] << '\n';
}
// Витрати:
// #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 << "Немає витрат\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 << "Найбільша витрата: #" << best_i
<< " = " << expenses[best_i] << '\n';
// Найбільша витрата: #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 << "Різниці:\n";
for (std::size_t i = 0; i + 1 < expenses.size(); ++i) {
std::cout << (expenses[i + 1] - expenses[i]) << '\n';
}
// Різниці:
// 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 << "Спочатку новіші:\n";
for (std::size_t i = expenses.size(); i-- > 0; ) {
std::cout << expenses[i] << '\n';
}
// Спочатку новіші:
// 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 << "Індекс має бути >= 0\n";
return 0;
}
std::size_t i = static_cast<std::size_t>(user_index);
if (i >= expenses.size()) {
std::cout << "Індекс поза межами\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), компілятор може робити неявні перетворення, і відʼємні числа раптово стають величезними додатними. Це особливо неприємно під час перевірки меж для введення користувача: здається, що перевірка є, але вона спрацьовує навпаки.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ