Глава 8. Циклы `for` и `while`: автоматизация рутины
Задача главы: научиться обрабатывать списки оценок, журналы и выгрузки без дублирования кода — считать накопительные итоги, фильтровать мусорные строки и повторять проверку до наступления условия.
8.1 Цикл for: обход списков и последовательностей
Зачем это нужно. В выгрузке не один балл, а двести. Копировать print() двести раз невозможно — цикл for выполняет один и тот же блок кода для каждого элемента списка: пройти по всем баллам класса, напечатать уведомление каждому должнику.
Балл: 87 — зачётБалл: 45 — незачётБалл: 92 — зачётБалл: 63 — зачётБалл: 78 — зачётПеременная score — не отдельная сущность, а временное хранилище текущего элемента. На первом проходе в ней лежит 87, на втором 45, и так до конца списка. Имя выбирается по смыслу данных: for score in group_scores, for student in students, for row in csv_rows — так цикл читается как фраза на русском.
Обходить можно любую последовательность, не только список:
⚠️ Двоеточие в конце строки for ...: и отступ в 4 пробела обязательны. Без отступа Python не понимает, где начинается тело цикла, и падает с IndentationError: expected an indented block.
⚠️ Подводный камень
Нельзя удалять элементы из списка, по которому идёт итерация. Python обходит список по внутреннему счётчику индексов, а .remove() сдвигает элементы влево — счётчик перескакивает через соседа, и тот остаётся необработанным. Traceback не появится: ошибка тихая, часть мусора просто доедет до отчёта.
[87, -1, 45, 92]| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
| — (тихая ошибка) | «Индекс сместился, беру следующий по счёту» | Удалили первый -1 — список сжался, и второй -1 встал на позицию, которую цикл уже прошёл. Итерируйтесь по списку только для чтения, а результат складывайте в новый список. |
Правильный паттерн
Пояснение логики: исходная выгрузка остаётся неизменной — если фильтр окажется неверным, всегда видно, что именно пришло из системы. Второй допустимый вариант — итерироваться по копии for score in module_scores[:], а удалять из оригинала. Но «собрать новый список» надёжнее и читается лучше: сразу понятно, что попадёт в расчёт.
8.2 Накопители: паттерны «Счётчик» и «Сумматор»
Зачем это нужно. Почти любая метрика — это накопление: сумма баллов для среднего, количество сдавших для конверсии, максимум для «лучшего результата группы». Цикл проходит по данным, а переменная-накопитель хранит промежуточный итог между проходами.
Сумма баллов: 365Сдали: 4 из 5Средний балл: 73.0 | Лучший: 92Три накопителя — три роли:
| Паттерн | Инициализация | Шаг в цикле |
|---|---|---|
| Сумматор | total_score = 0 | total_score += score |
| Счётчик | passed_count = 0 | passed_count += 1 |
| Максимум | best_score = 0 | if score > best_score: best_score = score |
⚠️ С минимумом нулевая инициализация ломается: worst_score = 0 никогда не перезапишется, потому что балл не бывает меньше нуля. Стартовать нужно от заведомо большого значения — worst_score = 100 — или от первого элемента списка worst_score = group_scores[0].
⚠️ Подводный камень
Ловушка первая — накопитель объявлен внутри цикла. На каждом проходе он сбрасывается в ноль, поэтому в конце в нём остаётся вклад только последнего элемента.
Ловушка вторая — накопитель не объявлен вовсе. Здесь Python молчать не станет:
Traceback (most recent call last): File "report.py", line 4, in <module> total_score += scoreNameError: name 'total_score' is not defined| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
NameError | «Имя total_score в этой области не определено» | += читает старое значение переменной перед тем, как записать новое. Читать нечего — накопитель не создан. Поставьте total_score = 0 до for. |
| — (тихая ошибка) | «Присваиваю ноль на каждой итерации» | Строка total_score = 0 попала внутрь тела цикла. Итог обнуляется перед каждым прибавлением, в отчёт уходит значение последней строки выгрузки. |
Мнемоника: инициализация — до цикла, изменение — внутри цикла. Если строка начинается с имя = 0, ей место над for.
Правильный паттерн
Пояснение логики: накопители объявлены до цикла, поэтому после пустой выгрузки они честно равны нулю, а не рушат программу. А вот total_score / len(group_scores) на пустом списке даст ZeroDivisionError — среднее по нулю строк математически не существует. Проверка len() > 0 перед делением обязательна везде, где данные приходят извне.
8.3 Функция range() и итерирование по индексам
Зачем это нужно. Иногда обходить нечего — нужно выполнить действие ровно N раз: сгенерировать 5 пустых колонок в шаблоне отчёта, распечатать шкалу баллов с шагом 10. А иногда нужен не сам элемент, а его номер: чтобы пронумеровать строки ведомости или сопоставить два параллельных списка.
Связка range(len(...)) даёт доступ по индексу — это единственный способ идти по двум параллельным спискам одновременно:
1. Иванов — 87 б.2. Петрова — 45 б.3. Сидоров — 92 б.⚠️ Подводный камень
Правая граница в range() не включается. То же правило, что и у срезов из главы Строки и f-строки: range(1, 5) выдаёт 1, 2, 3, 4 — четыре значения, а не пять. Ошибка «на единицу» (off-by-one) — самая частая в отчётах: последний ученик или последняя попытка теряются.
Попытка 1 из 5Попытка 2 из 5Попытка 3 из 5Попытка 4 из 5Пятой попытки в отчёте нет. Ошибка тихая — скрипт отработал «успешно».
Вторая ловушка того же семейства — range(len()) по разным спискам. В выгрузке балл выгрузился не у всех, списки разъехались по длине, и здесь Python уже падает:
Иванов — 87Петрова — 45Traceback (most recent call last): File "report.py", line 5, in <module> print(f'{students[i]} — {scores[i]}')IndexError: list index out of range| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
| — (тихая ошибка) | «stop — граница, за которую я не захожу» | range(1, 5) — это 1, 2, 3, 4. Для нумерации от единицы до N пишите range(1, count + 1). |
IndexError | «Индекс 2 за пределами списка длиной 2» | Длину взяли у одного списка, а индексируете другой — короче. Считайте длину по короткому списку либо проверяйте len(students) == len(scores) до цикла. |
Правильный паттерн
Пояснение логики: + 1 в правой границе — стандартная поправка на невключение, её проще запомнить как обязательный элемент шаблона «пронумеровать N штук». Проверка равенства длин ставится до цикла: расхождение в выгрузке — это событие для отчёта, а не повод уронить скрипт на середине.
Бонус: когда нужны и номер, и элемент одновременно, есть более короткая запись — enumerate():
range(len(...)) остаётся незаменимым там, где по индексу нужно достать элемент другого списка.
8.4 Прерывание и пропуск: break и continue
Зачем это нужно. continue — «этот элемент мне не подходит, дальше»: пропустить пустые строки, не явившихся учеников, пропущенные уроки. break — экстренный останов: нашли в выгрузке признак фальсификации, дальше считать бессмысленно, итог всё равно недостоверен.
Учтён балл: 78Урок пропущен, не учитываемУчтён балл: 92⚠️ Балл 105 выше максимума — расчёт остановленИтого набрано: 170Разница в одном: continue бросает текущий проход и берёт следующий элемент, break бросает весь цикл целиком. Балл 64 после break не обработан — и это ровно то поведение, которое требовалось.
⚠️ Подводный камень
break выходит только из своего, самого внутреннего цикла. Во вложенном обходе — группы, внутри групп баллы — он останавливает разбор одной группы, а внешний цикл спокойно едет дальше, как будто ничего не случилось.
Группа [78, 92] обработанаНайден битый баллГруппа [64, 105] обработанаГруппа [88, 90] обработанаГруппа с баллом 105 помечена как обработанная, и скрипт продолжил считать остальные — хотя решение было «останов при любой фальсификации».
| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
| — (тихая ошибка) | «break прерывает цикл, в котором он написан» | Ждали выхода из обоих циклов, получили выход из внутреннего. Заведите флаг (has_error = True), а на внешнем уровне проверьте его и сделайте второй break. |
Вторая ловушка — не техническая, а читательская: если break и continue рассыпаны по всему телу цикла вперемешку с расчётами, логику «что и когда учитывается» невозможно восстановить глазами. Это та самая «спагетти-логика».
Правильный паттерн
Группа [78, 92] обработана⚠️ Расчёт прерван: обнаружены недостоверные данныеПояснение логики: флаг has_error — это память о событии, которая живёт дольше одного цикла. Она же используется в конце для выбора текста отчёта: сам факт break после выхода из цикла уже не виден, а флаг виден.
И правило чтения: все continue-фильтры ставятся в самом начале тела цикла, до расчётов. Тогда цикл читается в два блока — «сначала выбрасываем мусор, потом считаем по чистым данным»:
8.5 Цикл while: обработка условий и интерактивный ввод
Зачем это нужно. for знает заранее, сколько будет проходов — столько, сколько элементов. while не знает: он повторяет блок, пока условие истинно. Классика для аналитика — запрашивать балл, пока оператор не введёт корректное значение от 0 до 100.
Балл от 0 до 100: 105⚠️ Балл вне диапазона, повторите вводБалл от 0 до 100: 87Принят балл: 87Второй рабочий шаблон — счётчик попыток, чтобы оператор не застрял навсегда:
⚠️ Подводный камень
Бесконечный цикл (infinite loop). Если переменная из условия внутри тела не меняется, условие остаётся истинным навсегда. Программа не падает — она «зависает» и печатает одно и то же, пока её не убьют вручную через Ctrl + C.
Проверяем следующую строку выгрузки...Проверяем следующую строку выгрузки...Проверяем следующую строку выгрузки...^CTraceback (most recent call last): File "report.py", line 4, in <module> print('Проверяем следующую строку выгрузки...')KeyboardInterruptЛовушка вторая, коварнее: continue в while перепрыгивает через шаг счётчика, если тот стоит в конце тела.
Скрипт напечатает Иванов;87 и навсегда зависнет на пустой строке с индексом 1.
| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
| — (тихая ошибка) | «Условие всё ещё True, повторяю тело» | Забыли изменить переменную условия. В теле while обязательно должна быть строка, которая приближает выход: attempts_left -= 1, row_index += 1, is_valid = True или break. |
KeyboardInterrupt | «Выполнение прервано пользователем (Ctrl + C)» | Это не ошибка кода, а способ убить зависший цикл. Увидели её после своего while — ищите непеременяющееся условие. |
Правильный паттерн
Иванов — 87 б.Петрова — 45 б.Пояснение логики: счётчик увеличивается до всех continue и break, поэтому ни один путь исполнения не может «застрять» на текущей строке. Текущий элемент заранее скопирован в current_row — читается лучше, чем csv_rows[row_index] в пяти местах.
И главный вывод раздела: если количество проходов известно заранее — берите for. Пример выше написан на while только для демонстрации; в реальном коде обход списка строк — это for current_row in csv_rows, где зациклиться невозможно по устройству. while оставляют для интерактивного ввода, ожидания события и повторных попыток.
8.6 Мини-словарик ошибок восьмой главы
| Ошибка | Что значит для Python | Человеческий перевод |
|---|---|---|
NameError | «Имя total_score не определено» | Использовали накопитель, не объявив его до цикла. += требует, чтобы переменная уже существовала. |
UnboundLocalError | «Локальная переменная использована до присваивания» | Тот же случай, но внутри функции: накопитель объявлен снаружи, а меняется внутри. Разбор — в главе Функции. |
IndexError | «Индекс за пределами списка» | range(len(students)), а индексируется более короткий scores. Проверяйте длины до цикла. |
ZeroDivisionError | «Деление на ноль» | Считали среднее по пустой выгрузке: total_score / len(group_scores), где длина 0. |
TypeError | «Объект int не является итерируемым» | Написали for score in 87 — по одному числу пройти нельзя, нужна последовательность. |
ValueError | «Не могу превратить 'не явился' в число» | int(input()) внутри while: оператор ввёл текст вместо балла. |
IndentationError | «Ожидался блок с отступом» | Забыли отступ в теле цикла после for ...:. |
KeyboardInterrupt | «Прервано пользователем» | Убили Ctrl + C зависший while — условие не менялось. |
| — (тихая ошибка) | «Присваиваю ноль на каждой итерации» | total_score = 0 внутри тела цикла: в итоге остался вклад только последнего элемента. |
| — (тихая ошибка) | «stop не включается» | range(1, 5) вместо range(1, 6) — последний ученик или последняя попытка потерялись. |
| — (тихая ошибка) | «Индекс сместился после удаления» | Удаляли элементы из списка во время итерации по нему — часть мусора осталась. |
| — (тихая ошибка) | «continue — прыжок к проверке условия» | В while счётчик стоял после continue и не выполнялся. Бесконечный цикл. |
Самый обидный случай целиком — TypeError: 'int' object is not iterable. Он приходит, когда в переменной ждали список баллов, а пришёл один балл: так бывает, если выгрузка содержит единственную строку и система вернула не список, а скалярное значение.
Traceback (most recent call last): File "report.py", line 3, in <module> for score in group_scores:TypeError: 'int' object is not iterable«Итерируемый» — значит «состоящий из элементов, которые можно перебрать». Список, строка, кортеж — итерируемы; int и float — нет. Лечится нормализацией данных на входе:
Мини-кейс: расчёт конверсии прохождения курса
Задача: по списку баллов за модули посчитать сумму и средний балл ученика, пропустив пропущенные уроки (-1), посчитать конверсию прохождения (доля модулей, сданных на 60+) и прервать расчёт, если обнаружен факт фальсификации — балл выше 100.
Прогон на данных из кейса:
--- КОНВЕРСИЯ КУРСА: Иванов Иван ---Учтено модулей: 5 | Пропущено: 2Сумма баллов: 398 | Средний: 79.6 | Лучший: 95Сдано на 60+: 4 из 5Конверсия прохождения: 80%Тот же скрипт на выгрузке с фальсификацией — module_scores = [78, -1, 92, 120, 88]:
--- КОНВЕРСИЯ КУРСА: Иванов Иван ---⚠️ Расчёт прерван: найден балл выше максимума. Выгрузка требует проверки.Что получилось: один проход по списку дал сразу шесть метрик — сумму, количество учтённых, количество пропусков, количество сдач, максимум и флаг достоверности. Все накопители объявлены до цикла, поэтому на пустой выгрузке скрипт не упадёт, а честно сообщит, что считать нечего. Фильтр пропусков стоит первым — continue отсекает -1 до всех расчётов, и средний балл считается по counted_modules, а не по len(module_scores): пропущенные уроки не занижают результат. Проверка на балл выше максимума ставит флаг и делает break, а решение «что печатать» принимается уже после цикла — по флагу, а не по факту прерывания.
⚠️ Одна дыра осталась: если в выгрузке вместо числа окажется строка 'не явился', сравнение score > max_score упадёт с TypeError: '>' not supported between instances of 'str' and 'int'. Защита от таких данных — try / except из главы Ошибки и исключения. А среднее и максимум по чистому списку можно считать и без ручных накопителей — готовыми функциями из главы Стандартная библиотека.
Чек-лист главы
- Пишу
for item in sequence:с двоеточием и отступом в 4 пробела - Даю переменной цикла говорящее имя:
for score in group_scores, а неfor x in data - Не удаляю элементы из списка, по которому иду, — собираю новый очищенный список
- Объявляю накопители (
total_score = 0,passed_count = 0) строго до цикла - Помню, что для минимума нулевая инициализация не работает — стартую от
100или от первого элемента - Проверяю
len(...) > 0перед делением на количество, чтобы не пойматьZeroDivisionError - Помню, что правая граница
range()не включается, и пишуrange(1, count + 1)для нумерации от единицы - Перед
range(len(...))по двум спискам сверяю их длины - Ставлю все
continue-фильтры в начале тела цикла, до расчётов - Знаю, что
breakвыходит только из внутреннего цикла, и использую флаг для выхода из вложенных - В каждом
whileвижу строку, которая меняет условие, — иначе будет бесконечный цикл - В
whileувеличиваю счётчик доcontinue, а не после - Беру
for, когда число проходов известно, иwhile— только для ввода и ожидания события - Узнаю
NameError(накопитель не объявлен) иTypeError: 'int' object is not iterable(вместо списка пришло число)
Дальше: Функции