JavaRush /Курси /C++ SELF /Заглиблюємося в індексні цикли та

Заглиблюємося в індексні цикли та std::size_t

C++ SELF
Рівень 13 , Лекція 4
Відкрита

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. Якщо istd::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
for (std::size_t i = 0; i < n; ++i)
i < n ніколи не дає i == n
За сусідами i і i+1
for (std::size_t i = 0; i + 1 < n; ++i)
гарантує, що i+1 існує
За сусідами i-1 і i
for (std::size_t i = 1; i < n; ++i)
гарантує, що i-1 існує
Зворотний обхід n-1..0
for (std::size_t i = n; i-- > 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_tfor (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), компілятор може робити неявні перетворення, і відʼємні числа раптово стають величезними додатними. Це особливо неприємно під час перевірки меж для введення користувача: здається, що перевірка є, але вона спрацьовує навпаки.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ