JavaRush /Курси /C++ SELF /Занурюємося в ітератори

Занурюємося в ітератори

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

1. Вступ

Коли ви лише починаєте програмувати, здається логічним: «якщо в мене є std::vector<int> v, то нехай функція приймає v і робить усе, що потрібно». І це справді так… доки ви не захочете обробити не весь контейнер, а лише його частину: перші 10 елементів, елементи з 2‑го по 5‑й або все, крім першого. Саме тут і починається магія — хоча насправді це просто математика діапазону.

У стандартній бібліотеці C++ майже всі алгоритми працюють за однаковим принципом: «мені не важливо, який це контейнер; покажи, де початок і де кінець, — і я пройдуся по елементах». Саме тому алгоритми такі універсальні: один і той самий std::find може працювати з vector, string, array і навіть зі «звичайним» масивом.

Щоб це працювало, бібліотеці потрібна спільна мова. Ця мова — ітератори і пара функцій begin() / end().

Ітератор: вказівник із правилами

Якщо говорити просто, ітератор — це «штука, яка вказує на елемент послідовності й уміє рухатися далі». Він схожий на вказівник, але зазвичай безпечніший і розумніший: привʼязаний до контейнера й знає його правила. Ідея здається абстрактною рівно доти, доки ви не спробуєте обійти контейнер різними способами й не побачите, що підхід з ітераторами напрочуд добре поєднується з бібліотекою.

Невеликий приклад: пройдемося по vector за допомогою ітераторів і надрукуємо елементи. Тут важливо побачити три дії: «взяти початок», «зупинитися в кінці» й «розіменувати поточний елемент».

#include <iostream>
#include <vector>

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

    for (auto it = v.begin(); it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 10 20 30
}

Зверніть увагу на деталь: умова it != v.end() — це не «для краси», а правило безпеки. end() — це межа, а не елемент.

2. Діапазон [first, last): включаємо початок, виключаємо кінець

Тепер переходимо до найважливішої ідеї сьогоднішньої лекції. У STL діапазон зазвичай задають не як «з першого по останній включно», а як [first, last): first включений, last — ні. Спершу це може здаватися дивною програмістською традицією, але згодом виявляється, що це дуже зручно.

Чому це зручно? Тому що порожній діапазон можна записати однаковими ітераторами: first == last. Тому що довжина діапазону природно обчислюється як кількість кроків від first до last. І тому що весь контейнер — це просто begin()end() без жодних спеціальних випадків.

Намалюймо просту схему діапазону для вектора з трьох елементів:

flowchart LR
    A["begin()"] --> B["елемент 0"]
    B --> C["елемент 1"]
    C --> D["елемент 2"]
    D --> E["end() (за останнім)"]

end() стоїть після останнього елемента, ніби «клітинка за фінішною рискою».

Невеликий приклад: пройдемося не по всьому vector, а лише по «хвосту», починаючи з другого елемента. Ми візьмемо begin(), зробимо ++it один раз і рухатимемося до end().

#include <iostream>
#include <vector>

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

    auto it = v.begin();
    ++it; // тепер вказує на 20

    for (; it != v.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 20 30
}

У цьому й полягає сила діапазону: ви можете зсунути початок, а кінець лишиться тим самим.

3. Межі діапазону та константні ітератори

Чому end() не можна розіменовувати

У програмуванні є бажання, які краще не виконувати. Розіменувати end() — одне з них. end() не вказує на елемент, він вказує на позицію за останнім. Це як адреса «наступного після останнього стільця» в аудиторії: стільця там немає, але сама позиція в просторі існує.

Якщо ви напишете *v.end(), ви порушите контракт ітераторів. У найкращому разі отримаєте дивні дані, у найгіршому — падіння, а в найвеселішому — «працює на моєму компʼютері».

Правильний підхід простий: перш ніж розіменовувати ітератор, потрібно переконатися, що він не дорівнює end().

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3};

    auto it = v.end();
    if (it == v.end()) {
        std::cout << "it is end(), no element here\n";
    }
}

Цей підхід — спочатку перевірити, а потім використовувати — знадобиться ще багато разів, особливо коли ми почнемо отримувати ітератори як результат роботи алгоритмів.

begin()/end() і cbegin()/cend(): лише читання

У контейнерів є begin()/end(), а ще часто — cbegin()/cend(). Літера c означає const, тобто: «я обіцяю, що не буду змінювати елементи».

Це корисно з двох причин. По-перше, компілятор справді допомагає: якщо ви випадково спробуєте змінити елемент, він цього не дозволить. По-друге, такий код легше читати: ви відкриваєте функцію й одразу розумієте, що вона не змінює контейнер через цей ітератор.

Невеликий приклад: друкуємо vector, але одразу показуємо, що це режим «лише читання».

#include <iostream>
#include <vector>

int main() {
    const std::vector<int> v{4, 5, 6};

    for (auto it = v.cbegin(); it != v.cend(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n'; // 4 5 6
}

Якби ми спробували зробити *it = 42;, компілятор нас зупинив би. Це приємний момент: компілятор — ваш суворий викладач, який не дає списувати.

4. Алгоритми та ітератор результату: приклад std::find

«Увесь контейнер» як діапазон

Зафіксуймо головну думку лекції: коли STL‑алгоритм каже «дай мені дані», він зазвичай очікує два ітератори: початок і кінець діапазону. А «увесь контейнер» просто задається як c.begin(), c.end() (або cbegin()/cend()).

Ця форма настільки стандартна, що ви бачитимете її постійно:

algo(c.begin(), c.end(), ...);

або, якщо ми не плануємо змінювати контейнер і хочемо це підкреслити:

algo(c.cbegin(), c.cend(), ...);

std::find: зразок інтерфейсу

Як приклад такого інтерфейсу розгляньмо std::find. Він шукає значення в діапазоні й повертає ітератор: або «де знайшли», або last (тобто зазвичай end()), якщо нічого не знайшли.

#include <algorithm>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> v{5, 7, 9};

    auto it = std::find(v.begin(), v.end(), 7);
    if (it != v.end()) {
        std::cout << "found: " << *it << '\n'; // found: 7
    } else {
        std::cout << "not found\n";
    }
}

Ключовий момент: результат find не можна розіменовувати наосліп. Він може виявитися рівним end().

Ітератор результату: «позиція знайденого» або «маркер не знайдено»

Багато алгоритмів у STL повертають не індекс і не true/false, а саме ітератор результату. Це зручно, бо ітератор — це водночас і позиція, і можливість щось зробити з елементом: прочитати його або змінити, якщо це дозволяє контракт.

Такий результат зазвичай читають так: «ось місце, де знайдено». А якщо не знайдено — «місце дорівнює last».

Подивімося на це на простому прикладі з рядком. Для std::string теж є ітератори, і алгоритми працюють із рядком майже так само, як і з vector.

#include <algorithm>
#include <iostream>
#include <string>

int main() {
    std::string s = "cat";

    auto it = std::find(s.begin(), s.end(), 'a');
    if (it != s.end()) {
        std::cout << "letter: " << *it << '\n'; // letter: a
    }
}

Тут it — це «вказівник» на символ усередині рядка. І якщо рядок не константний, ми теоретично могли б замінити знайдену літеру, наприклад перетворити "cat" на "cut". Але зробимо це трохи пізніше, коли перейдемо до моделі задач.

5. Мініпроєкт: TaskTracker і пошук за id через ітератори

Тепер нам потрібен спільний приклад, який ми розвиватимемо далі: проста консольна програма, що зберігає задачі. Ми не створюємо «ідеальний менеджер задач», а робимо навчальний майданчик, на якому зручно тренувати контейнери, ітератори та алгоритмічний стиль.

Почнемо з моделі Task. Тут усе максимально просто: id, текст і прапорець done.

#include <string>

struct Task {
    int id{};
    std::string title;
    bool done{};
};

Тепер додамо виведення задач. Важливо: ми приймаємо const std::vector<Task>&, тому що друк не має змінювати список.

#include <iostream>
#include <vector>

void print_tasks(const std::vector<Task>& tasks) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        std::cout << it->id << ". " << it->title << " done=" << it->done << '\n';
    }
}

Зверніть увагу на it->id. Це зручніший запис замість (*it).id. Коли ітератор поводиться як вказівник, -> стає особливо зручним.

Пошук задачі за id: повертаємо ітератор

Тепер зробимо функцію «знайти задачу за id». І спеціально оформимо її в стилі STL: вона повертає ітератор. Якщо нічого не знайшли, повертаємо tasks.end().

Це дуже схоже на поведінку стандартних алгоритмів: «ось ітератор результату, а далі вирішуйте самі».

#include <vector>

std::vector<Task>::iterator find_task_by_id(std::vector<Task>& tasks, int id) {
    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.end();
}

Функція коротка, але вона вчить одразу трьох речей: обходити діапазон, шукати елемент і позначати стан «не знайдено». А головне — її інтерфейс уже сумісний із тим способом мислення, який знадобиться нам далі: ітератор як результат.

Використання: спочатку перевіряємо, потім користуємося

Покажемо, як безпечно користуватися цією функцією: спочатку перевіряємо it != end(), і лише потім змінюємо задачу.

#include <iostream>
#include <vector>

int main() {
    std::vector<Task> tasks{{1, "Learn C++", false}, {2, "Sleep", false}};

    auto it = find_task_by_id(tasks, 2);
    if (it != tasks.end()) {
        it->done = true;
    }

    print_tasks(tasks);
    // 1. Learn C++ done=0
    // 2. Sleep done=1
}

І ось тут стає очевидно, чому ітератор результату інколи зручніший за індекс. Ми знайшли елемент і одразу змінили його: не обчислювали, який це індекс, не робили другого проходу й не тримали зайвих змінних.

Константний пошук: const_iterator і cend()

Іноді вам потрібна версія пошуку «лише для читання». Тоді повертаємо const_iterator і використовуємо cbegin()/cend().

#include <vector>

std::vector<Task>::const_iterator find_task_by_id(const std::vector<Task>& tasks, int id) {
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        if (it->id == id) return it;
    }
    return tasks.cend();
}

Це той самий зміст, але контракт стає чіткішим: «я не збираюся змінювати задачі». І компілятор це контролюватиме.

6. Індекс і ітератор: коли що обирати

У багатьох новачків виникає запитання: «а чому не індекс? Він же простіший». Індекс справді простіший у vector, але щойно ви виходите за межі vector, індекс перестає бути універсальним. У рядка індекс є, у масиву — теж, але для багатьох структур даних індексу «за змістом» немає, а ітератор є.

Щоб закріпити загальну картину, погляньмо на таблицю порівняння. Її не треба завчати, але вона допомагає не плутати «позицію» та «ітератор».

Що порівнюємо Індекс Ітератор
Чи працює однаково з різними контейнерами Ні (залежить від контейнера) Так (це «універсальний інтерфейс»)
Чи можна задати «шматок контейнера» Можна, але часто незграбно Природно: [first, last)
Що повертає «пошук» Зазвичай індекс або -1 Ітератор або end()
Чи можна випадково вийти за межі Дуже легко Легко, якщо розіменувати end(), але шаблон перевірки рятує
Ідея «маркер не знайдено» -1 / size() / щось іще end() / last (єдиний стиль)

STL обрала ітератори, тому що вони дають змогу будувати універсальні алгоритми. А begin()/end() — це спосіб підʼєднати контейнер до цього підходу.

7. Типові помилки під час роботи з begin()/end() та ітераторами

Помилка № 1: розіменування end() наосліп.
Найчастіша проблема виглядає так: «я викликав find, отримав it і одразу пишу *it». Якщо елемент не знайдено, it буде рівним end(), а розіменування end() — це вихід за межі контракту. Виправлення просте: спочатку if (it != end()), а вже потім — доступ до елемента.

Помилка № 2: порівняння ітераторів із різних контейнерів.
Іноді через неуважність пишуть щось на кшталт if (it == other.end()). Ітератори можна порівнювати лише в межах одного контейнера або одного діапазону. У найкращому разі код не скомпілюється, у найгіршому — ви отримаєте дуже дивну логіку. Запамʼятайте правило: «ітератор знає, звідки він».

Помилка № 3: плутанина «ітератор — це індекс».
Ітератор — не число. Його не можна просто надрукувати як позицію. У загальному випадку до нього не можна довільно щось додавати, і не варто очікувати, що він поводиться як int. Якщо вам потрібна саме позиція, це окремий крок логіки. Але в алгоритмічному стилі вона часто й не потрібна: ітератора вже достатньо, щоб читати або змінювати елемент.

Помилка № 4: використання begin()/end() там, де ви обіцяли «лише читання».
Якщо функція має лише друкувати або перевіряти, а ви берете begin() (неконстантний ітератор), код стає менш захищеним: хтось потім «випадково» додасть зміну елемента — і компілятор промовчить. Звичка писати cbegin()/cend() у функціях читання робить код надійнішим, особливо у великих проєктах.

Помилка № 5: спроба «вилікувати» все за допомогою const без розуміння.
Іноді студент бачить помилку, додає const де завгодно й сподівається, що так усе запрацює. Якщо ви зробили контейнер const, то не зможете викликати алгоритми або функції, які мають змінювати порядок чи значення. Це не «примха компілятора», а захист контракту. Перш ніж додавати const, запитайте себе: «я справді хочу заборонити зміни чи просто намагаюся заглушити помилку?»

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