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, а код копіюють шматками. Тримайте ітератори «поруч» зі своїм контейнером і намагайтеся не розносити їх далеко по коду, доки не зʼявляться функції. Це буде трохи пізніше.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ