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 і розмова про інвалідування.
2. Як вибрати підхід і не перетворити код на ребус
Після трьох способів обходу хочеться простої відповіді: «Скажіть, який правильний, і на цьому все». Але правильність тут залежить від задачі. Нижче — компактна таблиця, яка допоможе ухвалити рішення без ворожіння на кавовій гущі й без містичних ритуалів навколо ++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, для «прочитати + індекс» — індекси, а для «позиції в контейнері» — ітератори.
3. Практичний приклад: журнал оцінок і три способи обходу
Зараз зберемо невеликий приклад, який більше схожий на справжній застосунок, а не на «три рядки заради трьох рядків». Нехай у нас буде проста програма: користувач вводить кілька оцінок, ми зберігаємо їх у 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, але сам принцип залишився б тим самим.
4. Типові помилки під час обходу 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 та подібні операції, можуть порушитися «позиції» і доступ до елементів. Сьогодні ми лише позначаємо це як попередження: поки ви вчитеся обходу, просто обходьте. А безпечні способи видалення і пояснення того, що саме «ламається», — це вже окрема тема.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ