JavaRush /Курси /C++ SELF /Ітератори: begin/end, cbegin/cend

Ітератори: begin/end, cbegin/cend

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

1. Навіщо потрібні ітератори та як вони влаштовані

Іноді здається: «Я ж можу написати for (const auto& x : v) — і все, життя вдалося». І часто так і є. Але часом потрібен точніший контроль: наприклад, пройти не весь контейнер, а лише його частину; зупинитися на знайденому елементі; акуратно змінювати елементи на місці; або просто навчитися читати STL-код, де цикли часто записують саме через ітератори. Ітератор — це своєрідний «ручний режим» обходу: символів трохи більше, зате й можливостей більше.

Уявіть, що range-for — це потяг, який їде від станції «перший елемент» до станції «останній елемент» за розкладом. А ітератори — це ваш особистий велосипед: ви самі обираєте швидкість, зупинки й маршрут. Так, інколи можна забути про шолом — тобто про перевірку end(), — але саме для цього ми й учимося.

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

Слово «ітератор» звучить так, ніби його вигадали спеціально, щоб лякати новачків. Подеколи в це навіть легко повірити. Але сама ідея проста: ітератор — це обʼєкт, який вказує на позицію в контейнері. Його часто порівнюють із вказівником, бо операції справді схожі: можна перейти до наступного елемента (++it), можна отримати поточний елемент (*it), можна порівняти з іншою позицією (it != end).

Чому «узагальнений»? Бо вказівник, по суті, працює лише з неперервною ділянкою памʼяті, як-от із масивом. А ітератор дає єдиний інтерфейс для різних контейнерів. std::vector зберігає елементи послідовно, std::array теж, але загалом у STL є контейнери, де все влаштовано складніше. Сьогодні ми залишаємося в межах vector/array й беремо від ітераторів найпрактичніше.

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

begin() і end(): «перший» і «після останнього»

Щоб ітератори були корисними, контейнер має вміти повертати дві ключові позиції: початок і кінець. У C++ для цього є пара методів begin() і end().

Дуже важливо зрозуміти одну річ — і далі все стане логічним: end() — це не останній елемент. Це позиція після останнього елемента. Тобто «межа», «стоп-лінія», «вихід» — як завгодно, але не сам елемент.

Ось схема, яку корисно тримати в голові:

begin() -> [ elem0 ][ elem1 ][ elem2 ] -> end()
           ^                      ^
          можна *                end() розіменовувати не можна

Чому так зроблено? Бо тоді дуже зручно писати цикл: «поки не дійшли до end() — працюємо». Якби кінець був «останнім елементом», цикл постійно плутався б із межами.

Невеликий приклад: просто наочно покажемо, як працюють begin() і end() та як можна рухатися від одного до іншого.

#include <iostream>
#include <vector>

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

    auto it = v.begin();
    std::cout << *it << '\n'; // 10
}

Тут it вказує на перший елемент, а *it читає його значення. Уже схоже на вказівник, еге ж?

Скелет ітераторного циклу: «ініціалізація → умова → крок»

Коли ви пишете цикл з індексом, то явно створюєте лічильник i, порівнюєте i < size() і робите ++i. В ітераторному циклі роль «лічильника» відіграє ітератор it, роль «межі» — end(), а роль «кроку» — ++it.

Класичний скелет виглядає так:

for (auto it = v.begin(); it != v.end(); ++it) {
    // робота з *it
}

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

Мініприклад: друк усіх елементів.

#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 — префіксний інкремент. Для звичайного int різниці майже немає, але для ітераторів у стилі C++ заведено писати саме ++it. Це зрозумілий сигнал: «ми просто рухаємося вперед», і в багатьох випадках такий запис потенційно дешевший, ніж it++.

Якщо хочеться побачити це як маленьку блок-схему, то ось:

flowchart TD
    A["отримати it = begin()"] --> B{"it != end()?"}
    B -- ні --> E[виходимо з циклу]
    B -- так --> C[використовуємо *it]
    C --> D[++it]
    D --> B

2. Доступ до елементів через ітератор

Розіменування *it: читання та зміна

Коли ви пишете *it, то отримуєте поточний елемент. І тут є важливий нюанс: найчастіше *it — це не копія, а доступ до елемента контейнера. Тобто у виразі *it ви працюєте зі «справжнім» елементом і можете змінювати його, якщо контейнер не const, а ітератор не є константним.

Порівняймо читання і зміну на прикладі нашого простого навчального застосунку «ToDo Lite», де задачі — це рядки в std::vector<std::string>.

Спочатку просто друк:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        std::cout << *it << '\n';
    }
}

Тепер модифікація: додамо до кожної задачі префікс "TODO: ".

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        *it = "TODO: " + *it;
    }

    for (const auto& s : tasks) {
        std::cout << s << '\n';
    }
    // TODO: buy milk
    // TODO: learn iterators
}

Тут *it ліворуч від = означає: «заміни елемент контейнера». Тобто це безпосередня зміна вектора. І саме тут ітератори особливо корисні: ви явно працюєте з «позиціями» в контейнері.

Ще один невеликий прийом для читабельності: іноді зручно одразу взяти посилання на елемент.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        auto& task = *it;          // task — посилання на елемент
        task += " [ok]";           // змінюємо елемент вектора
    }

    for (const auto& s : tasks) std::cout << s << '\n';
    // buy milk [ok]
    // learn iterators [ok]
}

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

cbegin()/cend() і const_iterator: режим «лише читання»

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

Для цього існують cbegin() і cend() — вони повертають константні ітератори, через які не можна змінювати елементи. У стандартній бібліотеці це окремий, більш явний режим обходу «лише для читання».

Ось приклад: порахуємо сумарну довжину задач, тобто виконаємо чисте читання.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    std::size_t total = 0;
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        total += it->size(); // можна і так: (*it).size()
    }

    std::cout << total << '\n'; // 23
}

Тут зʼявився оператор ->. Його можна читати так: «розіменуй і візьми поле або метод». Тобто it->size() — це те саме, що (*it).size(), але коротше й часто читається легше.

Важливо: якщо сам контейнер має тип const, то навіть begin() поверне ітератор лише для читання. Але cbegin() корисний тим, що намір видно відразу: «я не збираюся змінювати елементи». А наміри, як відомо, компілятор читає краще за деяких людей.

Покажімо, що модифікацію справді буде заборонено:

#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk"};

    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        // *it = "oops"; // так не можна: it — "лише читання"
    }
}

Коментар тут не просто для краси: якщо його розкоментувати, компілятор чесно повідомить, що так не можна.

Невелика історична ремарка на рівні ідеї: у стандартній бібліотеці є навіть вільні функції std::cbegin/std::cend. Вони зʼявилися не одразу: під час обговорення стандарту окремо порушували питання, чи справді вони потрібні. Запамʼятовувати це не обовʼязково, але корисно знати, що обхід у const-режимі — реальна й важлива практика, а не примха викладача.

3. Як range-for повʼязаний із begin()/end()

range-for виглядає як магія, але в основі тут лежить та сама ідея: є початок і кінець, і ми рухаємося від begin до end. Тобто range-for — це «цукор», тобто зручна форма запису, а ітератори — «основа», з якої цей цукор зроблений.

У дуже спрощеному вигляді можна думати так:

for (auto x : v) { ... }

це приблизно схоже на:

for (auto it = v.begin(); it != v.end(); ++it) {
    auto x = *it;
    ...
}

Це не буквальна трансляція — тут є нюанси. Але як навчальна модель такий підхід працює чудово: ви розумієте, чому range-for йде зліва направо, чому він не включає end() і чому «початок/кінець» — основа обходу контейнерів.

Перевіримо на практиці той самий ToDo-вектор: спочатку ітератори, потім range-for. Результат буде однаковий.

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators"};

    for (auto it = tasks.begin(); it != tasks.end(); ++it) {
        std::cout << *it << '\n';
    }

    for (const auto& t : tasks) {
        std::cout << t << '\n';
    }
}

Якщо ви бачите ітератори в чужому коді, це не означає, що автор хотів вас покарати. Часто це просто означає, що йому був потрібен трохи точніший контроль, ніж дає range-for.

4. Практика: нумерація та пошук

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

Друк задач з нумерацією

Номер елемента в ітераторі за замовчуванням не зберігається, тож просто заведемо окремий лічильник:

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};

    int index = 1;
    for (auto it = tasks.cbegin(); it != tasks.cend(); ++it) {
        std::cout << index << ") " << *it << '\n';
        ++index;
    }
    // 1) buy milk
    // 2) learn iterators
    // 3) sleep
}

Ми використовуємо cbegin()/cend(), тому що друк — це точно «лише читання», і нам приємно, коли компілятор теж це розуміє.

Пошук першої задачі, що містить підрядок

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

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<std::string> tasks{"buy milk", "learn iterators", "sleep"};
    std::string needle = "learn";

    auto it = tasks.begin();
    for (; it != tasks.end(); ++it) {
        if (it->find(needle) != std::string::npos) {
            break;
        }
    }

    if (it != tasks.end()) {
        std::cout << "Found: " << *it << '\n'; // Found: learn iterators
    } else {
        std::cout << "Not found\n";
    }
}

Тут варто відразу помітити кілька моментів.

По-перше, ми оголосили it перед циклом, бо хочемо скористатися ним і після циклу. Якщо оголосити auto it = ... просто всередині for, то після завершення циклу ітератор «помре» разом зі своєю областю видимості, і ми не зможемо перевірити результат.

По-друге, після циклу ми перевіряємо it != tasks.end(). Це ключовий прийом: «якщо дійшли до кінця — не знайшли; якщо зупинилися раніше — знайшли». І він працює саме тому, що end() означає «після останнього», тобто є однозначним маркером: «тут уже нічого немає».

5. Типові помилки під час роботи з ітераторами

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

Помилка № 2: очікування, що end() — це останній елемент.
Це дуже поширена й цілком зрозуміла помилка, бо слово «end» у голові легко читається як «кінець = останній». У STL це «кінець діапазону», а не «останній елемент». Тому умову циклу пишуть як it != end(), а не it <= end(), і не намагаються «включити end() в обробку». Якщо памʼятати формулу «end() = після останнього», більшість граничних проблем зникає.

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

Помилка № 4: забутий крок ++it і «вічний день бабака».
Ітераторний цикл — це звичайний for, а отже йому потрібен крок. Якщо випадково прибрати ++it, цикл зациклиться, а ви дивитиметеся на завислу програму й думатимете, що це якась «оптимізація компілятора». Тож уважно перевіряйте структуру: ініціалізація, умова, крок.

Помилка № 5: змішування ітераторів із різних контейнерів, або просто з різних векторів.
Порівнювати it від одного контейнера з end() від іншого просто безглуздо. Таке трапляється, коли змінні називаються майже однаково, наприклад v і v2, а код копіюють шматками. Тримайте ітератори «поруч» зі своїм контейнером і намагайтеся не розносити їх далеко по коду, доки не зʼявляться функції. Це буде трохи пізніше.

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