Python з нуля · Блок 3 · Тема 12

Цикли: обхід, а не лічильник

Шість тем поспіль ми складали структури даних і щоразу зупинялись на одному й тому самому місці: «обійти це ти навчишся в темі 12». Ось вона. Одна конструкція знімає обмеження з усього, що ми вже вміємо.

У темі 05 ми рахували, у що обходиться += над рядками, і чесно писали: «циклів ми ще не вчили, це тема 12». У темі 06 правильний рецепт таблиці [[0] * 3 for _ in range(3)] лишився замовлянням, яке треба було просто запамʼятати. У темі 08 біля .items() стояла врізка: «найчастіше це використовують у циклі — обходити ти навчишся за чотири теми». Чотири теми минуло.

Ця лекція нічого нового про структури даних не розповідає. Вона знімає обмеження: усе, що ми складали в списки, кортежі, словники й множини, нарешті можна пройти елемент за елементом і щось із кожним зробити. Наскрізний приклад лишається той самий, що й у темі 06, — кошик покупок:

покупки = ["хліб", "молоко", "яблука", "мед", "сіль"] ціни = {"хліб": 28.5, "молоко": 32.0, "яблука": 19.9, "мед": 145.0, "сіль": 12.0} кількості = [2, 1, 3, 1, 1]

01 / ОбхідГоловна відмінність від інших мов

У багатьох мовах цикл — це лічильник. Ти заводиш змінну, ставиш їй початкове значення, пишеш умову зупинки, пишеш, як її збільшувати, а потім всередині вручну дістаєш елемент за номером. Приблизно так це виглядає в C або Java:

for (int i = 0; i < 5; i++) { printf("%s\n", покупки[i]); }

У Python for влаштований інакше, і це не косметична різниця. Він не рахує. Він просить у колекції наступний елемент — і кладе його в імʼя:

for назва in покупки: print(назва)

Читається буквально як речення: «для кожної назви в покупках — надрукуй назву». Ніякого i, ніякої довжини, ніякого покупки[i]. Розберімо рядок по частинах:

Тіло виконується один раз на кожен елемент. Коли елементи скінчились, цикл завершується сам — писати умову зупинки не треба, бо колекція сама знає, де в неї кінець. Перший рядок без відступу після циклу виконається вже після всіх повторень.

Найкорисніше, що можна зробити в циклі, — накопичувати. Заводимо змінну до циклу й на кожному кроці її доповнюємо:

разом = 0 for ціна in [28.5, 32.0, 19.9]: разом += ціна print(разом) # 80.4

Змінна разом оголошена зовні, і це принципово: якби рядок разом = 0 стояв усередині з відступом, він виконувався б на кожному кроці й обнуляв би все зроблене. Пройди цей код покроково — і перемкни колекцію, щоб побачити, що механіка не залежить від того, що ми обходимо.

Інтерактив 1 · Крок за кроком

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

крок
поточний елемент
Дві речі, які легко проґавити. Перша: змінна циклу не зникає після циклу — вона лишається живою й тримає останнє значення. Друга: у режимі «словник» змінних дві, назва і ціна. Це те саме розпакування кортежу, що й у темі 07: .items() віддає пари, а for одразу їх розкладає.

02 / ОбіцянкиЩо саме можна обходити

Тепер найприємніше. Оскільки for нічого не рахує, а просить наступний елемент, йому байдуже, у кого просити. Обходиться будь-що, що вміє віддавати елементи по черзі, — такі обʼєкти називають ітерованими (iterable). Пройдімось по всьому, що ми вже вивчили.

Список — елементи в порядку зберігання

>>> for назва in покупки: ... print(назва) хліб молоко яблука мед сіль

Порядок гарантований: список упорядкований, і обхід іде зліва направо.

Рядок — по одному символу

>>> for символ in "мед": ... print(символ) м е д

Це та сама послідовність, що й у темі 05, тільки тепер ми йдемо по ній замість брати зріз. Тут же ховається класична помилка: for слово in "мед і хліб" дасть символи, а не слова. Щоб отримати слова, спершу поділи рядок: for слово in "мед і хліб".split().

Множина — без порядку

>>> знижки = {"мед", "сіль"} >>> for назва in знижки: ... print(назва)

Обхід працює, але порядок не визначений — множина з теми 09 зберігає елементи так, як їй зручно за хешем. Покладатись на порядок виводу тут не можна. Потрібен передбачуваний порядок — обходь sorted(знижки).

Словник — і три його вікна

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

>>> for назва in ціни: # те саме, що for назва in ціни.keys() ... print(назва)

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

як обходимощо дає змінна циклуколи брати
for k in ціниключзначення не потрібне
for v in ціни.values()значенняключ не потрібен: сума, максимум
for k, v in ціни.items()ключ і значення одразунайчастіший випадок
for k in sorted(ціни)ключ, в упорядкованому виглядітреба стабільний вивід
разом = 0 for назва, ціна in ціни.items(): print(назва, "коштує", ціна) разом += ціна print("усього позицій на", разом) # усього позицій на 237.4

Форма for назва, ціна in ... — це не особлива магія словника, а розпакування з теми 07. .items() віддає пари ('хліб', 28.5), а for одразу розкладає кожну пару на два імені. Так само працює обхід будь-якого списку кортежів.

Кортеж теж обходиться. Тема 07 дала незмінну послідовність — і вона така сама ітерована, як список. Різниця лише в тому, що змінити кортеж під час обходу неможливо в принципі, тож пастка з розділу 09 на нього не поширюється. Це, до речі, ще один аргумент на користь кортежів, який ми там не могли пояснити.

03 / rangeКоли потрібні саме числа

А що, як обходити треба не колекцію, а просто «п'ять разів»? Для цього є range — обʼєкт, який видає цілі числа за арифметичним правилом:

>>> for i in range(5): ... print(i) 0 1 2 3 4

Три форми виклику, і всі три варто знати напамʼять:

Правило «стоп не входить» здається примхою, поки не помітиш, що воно робить довжину очевидною: у range(0, 5) рівно 5 значень, а range(a, b) має рівно b − a значень. Загальна формула кількості — з округленням угору:

кількість = max(0, ⌈(стоп − старт) / крок⌉)

Підстав range(2, 12, 3): (12 − 2) / 3 = 3.33…, угору — 4. І справді 2, 5, 8, 11 — чотири числа, останнє менше за 12. Покрути повзунки й перевір формулу на будь-яких значеннях, включно з відʼємним кроком і порожнім результатом:

Інтерактив 2 · range наочно

Числова вісь. Рожеві кружечки — те, що range справді видасть; дуги показують крок.

значень
останнє
Три стани, які варто зловити. Крок 0 — це ValueError, і слушно: значення ніколи не зрушило б з місця. Старт праворуч від стопу при додатному кроці дає порожній range — не помилку, а нуль повторень. І відʼємний крок працює дзеркально: тоді порожнім буде вже випадок «старт лівіше за стоп».

Тепер найважливіше про range, і це те, що майже ніколи не пояснюють новачкам. range — не список. Він не зберігає числа, а обчислює чергове за формулою старт + i · крок у момент, коли його попросили. Перевір напряму:

>>> range(5) range(0, 5) # не [0, 1, 2, 3, 4] >>> list(range(5)) [0, 1, 2, 3, 4] # ось тепер список >>> import sys >>> sys.getsizeof(range(1_000_000)) 48 # байтів >>> sys.getsizeof(list(range(1_000_000))) 8000056 # теж байтів

Мільйон чисел у range займає 48 байтів — стільки ж, скільки й пʼять чисел. Він тримає лише старт, стоп і крок. Той самий мільйон у списку — вісім мегабайтів. Тому for i in range(1_000_000) запускається миттєво й не їсть памʼять, а for i in list(range(1_000_000)) спершу чесно збудує весь список.

Але не переходь на range без потреби. Якщо в тебе вже є колекція — обходь її, а не її індекси. Запис for i in range(len(покупки)) з наступним покупки[i] — майже завжди ознака того, що людина писала цикл за звичкою з іншої мови. Він довший, повільніший і ламається, щойно колекція виявиться множиною або словником, де індексів немає взагалі. До цього ми ще повернемось у розділі 10.

04 / Париenumerate і zip

Іноді індекс усе-таки потрібен — наприклад, щоб надрукувати пронумерований чек. Для цього є enumerate: він бере ітерований обʼєкт і віддає пари «номер, елемент».

>>> for номер, назва in enumerate(покупки): ... print(номер, назва) 0 хліб 1 молоко 2 яблука 3 мед 4 сіль

Нумерація починається з нуля, як усюди в Python. Але людям звичніше з одиниці — тому в enumerate є другий аргумент: enumerate(покупки, start=1) дасть 1, 2, 3, 4, 5. Важливо: start зсуває лише лічильник, а не початок обходу. Перший елемент усе одно буде «хліб», просто з номером 1.

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

>>> for назва, скільки in zip(покупки, кількості): ... print(назва, "×", скільки) хліб × 2 молоко × 1 яблука × 3 мед × 1 сіль × 1

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

Інтерактив 3 · enumerate і zip

Ліворуч — джерела, праворуч — пари, які отримає цикл на кожному кроці.

пар утвориться
лишилось без пари
перша пара
Останній режим — головний. Списки різної довжини, і zip мовчки обрізає обхід по коротшому: пʼятий елемент просто ніколи не потрапить у цикл. Якщо така розбіжність — це помилка в даних, у Python 3.10+ є zip(a, b, strict=True): він кине ValueError замість того, щоб мовчати.

05 / Керуванняbreak і continue

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

Найкраще різниця видно, коли той самий цикл виконати тричі, міняючи лише одне слово. Рахуємо суму цін, але дорогі позиції (понад 100 грн) хочемо обійти:

разом = 0 for ціна in [28.5, 32.0, 19.9, 145.0, 12.0]: if ціна > 100: pass # ← сюди підставляємо continue або break разом += ціна print(разом)

Слово pass — це офіційна заглушка «нічого не роби»: Python вимагає, щоб у тілі if був хоч один рядок, і pass цю вимогу задовольняє, не роблячи нічого. Тепер підстав по черзі всі три варіанти:

Інтерактив 4 · break проти continue

Один і той самий цикл, змінюється лише одне слово в тілі.

додано
пропущено
не розглянуто
разом
Три різні відповіді на однакових даних: 237.4 з pass (додались усі пʼять), 92.4 з continue (мед пропущено, сіль додано) і 80.4 з break (на меді вийшли, солі навіть не бачили). Різниця між двома останніми — рівно одна позиція на 12 грн, і саме на такому пропущеному «хвості» ловляться справжні баги.
Обидва працюють і у while. Це не властивість for, а керування будь-яким циклом. І обидва діють лише на найближчий цикл: із вкладеного break вискочить у зовнішній, а не з обох одразу. Про вкладені цикли — розділ 08.

06 / elseГілка, яку майже всі розуміють неправильно

У Python у циклу може бути else. Конструкція рідкісна, і майже кожен, хто бачить її вперше, читає її неправильно — як «якщо цикл не виконався» або «якщо колекція порожня». Обидва прочитання хибні.

Правило одне, і воно просте: else виконується тоді, коли цикл завершився природно — тобто без break. Якщо break спрацював, гілка else пропускається.

Через це багато хто пропонує читати це слово не як «інакше», а як «якщо не перервали» — або, ще точніше, «nobreak». Класичне застосування — пошук:

шукане = "сир" for назва in покупки: if назва == шукане: print("знайшов:", назва) break else: print("немає такого в кошику")

Без else довелось би заводити окрему змінну-прапорець (знайдено = False, потім знайдено = True перед break, потім перевірка після циклу). Гілка else робить те саме без зайвої змінної. Зверни увагу на відступ: else вирівняно з for, а не з if — це else саме циклу.

Інтерактив 5 · Коли спрацьовує else у циклі

Три сценарії того самого пошуку. Внизу — дві гілки, підсвічена та, що виконалась.

перевірено
break
надруковано
Третій сценарій ламає інтуїцію найсильніше. Список порожній, тіло циклу не виконалось жодного разу — а else усе одно спрацював. Бо умова не «чи були кроки», а «чи був break». Не було — отже, гілка виконується.

07 / whileКоли кількість кроків невідома

for добрий, коли є що обходити. Але буває інакше: кроків стільки, скільки треба, і скільки саме — заздалегідь невідомо. Тоді береться while — «поки». Він повторює тіло, доки умова залишається істинною:

гаманець = 100 крок = 15 кроків = 0 while гаманець > 0: гаманець -= крок кроків += 1 print(кроків, гаманець) # 7 -5

Умова перевіряється перед кожним повторенням, у тому числі перед найпершим. Якщо вона хибна одразу, тіло не виконається жодного разу. Правила істинності — ті самі, що в темі 11: порожній список у while хибний, непорожній — істинний.

І тут зʼявляється єдина справжня небезпека циклів. Умова сама себе не змінить. Якщо в тілі немає нічого, що рухає її до хибності, — цикл не завершиться ніколи. Програма зависне, вентилятор загуде, і зупинити її доведеться через Ctrl+C. Постав крок у нуль і подивись, що станеться:

Інтерактив 6 · while і нескінченний цикл

Скільки списуємо з гаманця за один крок. Лінія показує, що відбувається з умовою гаманець > 0.

кроків
гаманець у кінці
Ліворуч від нуля цикл ламається двічі по-різному. Крок 0: рядок гаманець -= 0 виконується справно, але нічого не змінює — умова застигла істинною. Відʼємний крок гірший: гаманець росте, тобто кожне повторення віддаляє нас від виходу. Обидва випадки виглядають однаково ззовні — програма просто не відповідає.

Профілактика проста і складається з трьох звичок:

  1. Перед тим як писати while, скажи вголос, що саме в тілі наближає умову до хибності. Не можеш назвати рядок — цикл нескінченний.
  2. Перевір крайні значення. Нуль, відʼємне, порожній список — саме там ламається.
  3. Обмеж кількість спроб, якщо умова залежить від зовнішнього світу: while спроб < 5 and not вдалось:.

Окремо варто знати цілком легальну ідіому while True: — нескінченний цикл навмисне, з виходом через break усередині. Так пишуть, коли умову зручніше перевіряти не на початку, а посередині тіла: наприклад, спершу спитати щось у користувача, а потім вирішити, чи виходити. Різниця з випадковим зациклюванням одна: тут вихід є, і він видимий у коді.

Коли брати for, а коли while. Питай себе: «чи знаю я наперед, що саме обходжу?» Є колекція, є діапазон чисел — for, майже завжди. Кроки залежать від того, що станеться всередині (поки не збіжиться, поки користувач не введе правильно, поки в гаманці є гроші) — while. Спроба зробити на while те, що просилось на for, майже завжди дає довший код із зайвим лічильником.

08 / ВкладеніЦикл усередині циклу

Тіло циклу — звичайний код, отже, в ньому може стояти інший цикл. Внутрішній проходить повністю на кожен крок зовнішнього. Якщо зовнішній робить 3 кроки, а внутрішній 4, тіло внутрішнього виконається 3 × 4 = 12 разів.

Класична демонстрація — таблиця множення:

for рядок in range(1, 4): for стовпець in range(1, 5): print(рядок * стовпець, end="\t") print() # порожній print переводить рядок
1 2 3 4 2 4 6 8 3 6 9 12

Тут добре видно розподіл ролей: внутрішній print має відступ у вісім пробілів і належить внутрішньому циклу — він друкує клітинки. А print() з відступом у чотири пробіли належить зовнішньому циклу: він виконується один раз на рядок і переносить каретку. Помилка на один рівень відступу тут одразу видно в результаті — і це найкращий спосіб відчути, що відступ у Python не оформлення, а синтаксис.

У нашому кошику вкладені цикли дадуть прайс «скільки коштує k штук»:

for назва in ["хліб", "молоко"]: for скільки in [1, 2, 3]: print(назва, скільки, "шт —", ціни[назва] * скільки)

Про вкладені цикли треба знати одну неприємну річ: вони множать роботу. Два вкладені цикли по n кроків — це n² повторень тіла. На n = 100 це 10 тисяч, ще терпимо; на n = 10 000 це вже сто мільйонів, і програма думатиме хвилини. Коли бачиш вкладений цикл по тих самих даних — варто спитати, чи не можна замінити внутрішній пошук на множину або словник, де перевірка коштує O(1). Це рівно та розмова, яку ми вели в темі 10.

09 / ПасткаЗміна колекції під час обходу

А тепер найдорожча помилка цієї теми. Виглядає вона абсолютно природно: обходимо список і викидаємо з нього те, що не потрібне.

покупки = ["хліб", "сир", "сир", "мед"] for назва in покупки: if назва == "сир": покупки.remove(назва) >>> покупки ['хліб', 'сир', 'мед'] # ⚠ один сир лишився

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

Причина в тому, як for ходить по списку. Усередині він тримає прихований лічильник позиції: віддав елемент 0, збільшив на одиницю, віддав елемент 1, збільшив… Коли ти видаляєш елемент, усі, хто стояв правіше, зсуваються вліво на одну комірку — а лічильник про це не знає й однаково йде вперед. Один елемент проскакує повз. Пройди покроково й подивись на прихований індекс:

Інтерактив 7 · Чому елементи «перескакуються»

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

прихований i
елемент
Ключовий момент — третій крок. Після видалення другий «сир» переїхав на позицію 1, але лічильник уже дорівнює 2 і бере «мед». Позицію 1 ніхто більше не перевірить. Унизу той самий код обходить копію покупки[:]: копія не зсувається, бо змінюємо ми не її.

Правильних способів три, і всі три варто знати:

# 1 · обходимо копію, змінюємо оригінал for назва in покупки[:]: if назва == "сир": покупки.remove(назва) # 2 · збираємо новий список замість того, щоб псувати старий залишок = [] for назва in покупки: if назва != "сир": залишок.append(назва) покупки = залишок # 3 · ідемо з кінця, якщо потрібні саме індекси for i in range(len(покупки) - 1, -1, -1): if покупки[i] == "сир": покупки.pop(i)

Перший — найкоротший і найчастіший: квадратні дужки [:] роблять поверхневу копію (тема 06), цикл іде по ній, а remove псує оригінал — і ці двоє більше не заважають одне одному. Другий — найчесніший: нічого не ламаємо, а будуємо новий список. Саме він у темі 13 перетвориться на один рядок.

Зі словником і множиною гірше. Список хоча б мовчки дає неправильну відповідь. А от додавання чи видалення ключа під час обходу словника — це одразу RuntimeError: dictionary changed size during iteration. Виглядає суворіше, але насправді це подарунок: помилка краща за тихо зіпсовані дані. Ліки ті самі — обходь копію: for назва in list(ціни):.

10 / УсерединіІтератор, і чому він одноразовий

Розділ для тих, кому мало «працює — і добре». Ми весь час казали «for просить наступний елемент». У кого саме він просить?

Насправді за цим стоять два різні поняття. Ітерований обʼєкт (iterable) — це той, у кого можна попросити ітератор: список, рядок, словник, множина, range. Ітератор (iterator) — маленький обʼєкт із єдиним умінням: віддати наступний елемент і запамʼятати, де він зупинився. Цикл for — це синтаксичний цукор над трьома діями:

крок 1 · попросити ітератор

Функція iter() питає в колекції обʼєкт-обхідник. Для списку це list_iterator, який усередині тримає лише посилання на список і число — поточну позицію.

>>> обхідник = iter(["хліб", "мед"]) >>> обхідник <list_iterator object at 0x7f3c...>
крок 2 · брати, поки дають

Функція next() просить наступний елемент і зсуває позицію. Це буквально те, що робить for на кожному повторенні.

>>> next(обхідник) 'хліб' >>> next(обхідник) 'мед'
крок 3 · зловити кінець

Коли елементи скінчились, ітератор кидає особливий виняток StopIteration. Це не помилка, а сигнал: цикл for ловить його й тихо завершується. Побачити його «голим» можна, лише викликавши next() вручну.

>>> next(обхідник) StopIteration

З цієї механіки випливають три речі, які пояснюють раніші дивацтва.

Ітератор одноразовий. Він не вміє повертатись на початок — позиція тільки росте. Це не видно на списку (бо for щоразу просить новий ітератор), але одразу видно на zip чи enumerate, які самі є ітераторами:

пари = zip(покупки, кількості) for назва, скільки in пари: print(назва, скільки) # надрукувало 5 рядків for назва, скільки in пари: print(назва, скільки) # НІЧОГО: ітератор уже вичерпано

Другий цикл не впаде з помилкою — він просто не зробить жодного кроку, бо перший next() одразу дасть StopIteration. Це одна з найпідступніших несподіванок Python. Ліки: якщо результат потрібен двічі, зафіксуй його списком — пари = list(zip(покупки, кількості)).

Прихований лічильник — це поле ітератора. Ось звідки береться пастка з розділу 09: list_iterator тримає позицію, а не копію списку. Видалив елемент — список зсунувся, позиція лишилась. Тепер зрозуміло, чому обхід копії лікує: у копії ніхто нічого не зсуває.

І звідси ж — вирок конструкції for i in range(len(покупки)). Вона працює, але робить зайве коло: замість того щоб узяти елемент, який ітератор і так має віддати, ти береш число, а потім ідеш по нього в список ще раз. Порівняй три записи однієї задачі:

записщо не так
for i in range(len(покупки)):
    print(покупки[i])
звичка з інших мов: зайвий доступ за індексом, не працює для множини й словника
for i in range(len(покупки)):
    print(i, покупки[i])
уже краще, але це рівно те, що робить enumerate — і коротше
for i, назва in enumerate(покупки):
    print(i, назва)
так і треба

Єдиний випадок, де range(len(...)) справді доречний, — коли індекс потрібен не для читання, а для запису в той самий список: покупки[i] = покупки[i].upper(). Ітератор віддає значення, а не комірку, тож присвоїти через змінну циклу неможливо — і тоді номер комірки справді потрібен.

11 / ПідсумокЩо забрати з теми

Одна конструкція, яка знімає обмеження з усього попереднього блоку:

У темі 13 ми візьмемо найчастіший цикл із усіх — той, що збирає новий список з наявного, — і запишемо його одним рядком. Це спискове включення, і саме звідти беруться ті [[0] * 3 for _ in range(3)], які ми двічі просили просто запамʼятати. Тепер у тебе є все, щоб зрозуміти, чому цей рядок означає рівно те, що означає.

Далі в практиці. У practice.ipynb ти напишеш цикли підрахунку, пошуку й накопичення, обійдеш словник трьома способами, збудуєш таблицю множення вкладеними циклами — і власноруч відтвориш пастку зі зміною списку, щоб побачити неправильну відповідь на власному екрані. Кожен висновок перевіримо через assert.

Далі в темі

Теорію прочитано. Тепер закріпи її на практиці.