Трек «Инженерное программирование» · Модуль 1 · Неделя 14

Отладка как научный метод

воспроизведение · гипотеза · эксперимент · traceback · breakpoint() · тест-на-баг

2 ч теории. К концу недели — тест-на-баг → фикс в вашей CLI-утилите.

Знакомо: „у меня работает, а у тьютора нет"? Сегодня — почему это не про везение, а про метод.

«"У меня работает, а у тьютора нет" — это не про везение, а про метод»

Кто ловил себя на фразе «у меня работает»? Зафиксируем: «у меня работает» — это отсутствие воспроизведения, первый шаг метода.

«"Я поправил — стало хуже": правка без гипотезы»

# баг: функция возвращает на один элемент меньше def first_n(n): return list(range(n - 1)) print(first_n(5)) [0, 1, 2, 3]

Вопрос под кодом: Ожидали [0, 1, 2, 3, 4]. Что вы сделаете? — «поправлю» на range(n + 1) без гипотезы? (тогда станет [0, 1, 2, 3, 4, 5] — стало хуже).

Как бы вы чинили first_n(5)? Правка без гипотезы — лотерея: можно «починить» в обратную сторону.

«К концу лекции вы сможете…»

В конце — «Проверь себя»: это сигнал, где повторить, а не экзамен.

«Цикл отладки: воспроизвести → гипотеза → эксперимент → фикс → проверка»

цикл отладки новая гипотеза при опровержении 1 · воспроизвести стабильный, минимальный пример 2 · гипотеза о причине 3 · эксперимент подтверждает или опровергает 4 · фикс или новая гипотеза 5 · проверка тест зелёный, баг не воспроизводится
Запрещено менять код без гипотезы: правка наугад — лотерея.
На каком шаге находится тот, кто говорит „у меня работает"? (шаг 1 не пройден — нет воспроизведения)

«Шаг 1 — воспроизвести: минимальный стабильный пример, а не "иногда бывает"»

def avg(nums): return sum(nums) / len(nums) print(avg([1, 2, 3])) print(avg([])) 2.0 Traceback (most recent call last): File "debug.py", line 4, in <module> print(avg([])) File "debug.py", line 2, in avg return sum(nums) / len(nums) ZeroDivisionError: division by zero

Правило: «баг появляется иногда» — это отсутствие воспроизведения; воспроизвести = получить ошибку на минимальном стабильном примере (здесь — avg([])).

Почему «у меня работает» — это не воспроизведение? (нет данных, на которых баг проявляется)

«Шаг 2–3 — гипотеза и эксперимент: проверяем предположение, а не код»

# Гипотеза: avg падает на пустом списке, потому что len(nums) == 0. # Эксперимент: проверить предположение, добавив обработку пустого списка. def avg(nums): return sum(nums) / len(nums) if nums else 0.0 print(avg([1, 2, 3])) print(avg([])) 2.0 0.0

Правило: эксперимент подтвердил гипотезу (пустой список — причина); фикс следует из гипотезы, а не наоборот.

Что было бы, если бы гипотеза опроверглась? (новая гипотеза — стрелка назад на схеме)

«Так нельзя: правка наугад — лотерея, баг "плавает"»

# антипример: «исправление» наугад — баг становится «плавающим» def first_n(n): return list(range(n + 1)) # изменили без гипотезы print(first_n(5)) # [0, 1, 2, 3, 4, 5] — стало хуже print(first_n(0)) # [0] — а для 0 теперь вообще неверно [0, 1, 2, 3, 4, 5] [0]

Правило: правка без гипотезы не проверяет причину — она сдвигает ошибку («баг плавает»); метод требует гипотезы до правки.

Что произошло с first_n(0)? (было [] — а стало [0]). Как бы вы исправили правильно?

«Стоп-вопрос: почему первый шаг — воспроизведение, а не исправление?»

  1. Почему первый шаг отладки — воспроизведение, а не исправление?
  2. Что вы узнаёте из эксперимента, который опроверг гипотезу?

Эталон: без воспроизведения нет данных, на которых проверять фикс; опровергнутая гипотеза — это новая информация (причина не там) и новая гипотеза.

Пауза 10–15 секунд; затем — эталон и переход к чтению traceback.

«Traceback — это цепочка: тип, сообщение, файл, строка, вызовы»

# debug.py def load(path): return open(path).read() def main(): data = load("missing.txt") print(data) main() Traceback (most recent call last): File "/tmp/.../debug.py", line 8, in <module> main() File "/tmp/.../debug.py", line 5, in main data = load("missing.txt") File "/tmp/.../debug.py", line 2, in load return open(path).read() FileNotFoundError: [Errno 2] No such file or directory: 'missing.txt'

Правило: не читать только последнюю строку — полный стек показывает цепочку вызовов от места ошибки вверх к точке входа.

Что именно сообщает последняя строка? (тип исключения + точную причину в сообщении)

«Элементы traceback: что искать в каждой части»

Часть tracebackЧто искать
последняя строкатип исключения + точную причину в сообщении
блоки File "...", line Nгде именно возникло, какой вызов привёл
строка с ^ (Python 3.11)точное место в строке
цепочка блоковпуть от места ошибки к точке входа
raise ... fromпричинную связь между исключениями

Правило: сначала последняя строка (тип + причина), затем цепочка вызовов (какой вызов привёл).

Зачем читать блоки вызовов, если причина — в последней строке? (узнать, КАКОЙ вызов передал неверные данные)

«Порядок чтения: снизу вверх по стеку — где возникло, потом — что привело»

1 тип исключения и сообщение (самая нижняя строка) 2 место реального возникновения (ближайший блок к низу) 3 какой вызов его привёл (блоки выше) main точка входа load чтение файла open системный вызов FileNotFoundError ошибка: файл не найден
Читаем снизу вверх: где реально возникло, потом — какой вызов привёл; верхний блок — точка входа.
Ошибка возникла в load, а привёл её вызов из main — кто передал плохой путь?

«Причинная цепочка raise ... from — диагностический инструмент»

try: int("abc") except ValueError as e: raise RuntimeError("не удалось распарсить") from e Traceback (most recent call last): File "chain.py", line 2, in <module> int("abc") ValueError: invalid literal for int() with base 10: 'abc' The above exception was the direct cause of the following exception: Traceback (most recent call last): File "chain.py", line 4, in <module> raise RuntimeError("не удалось распарсить") from e RuntimeError: не удалось распарсить

Правило: from e сохраняет первопричину — traceback показывает оба исключения; без from e цепочка потерялась бы.

Что показывает фраза «The above exception was the direct cause»? (первопричину)

«Live-coding: breakpoint() вживую — n, s, c, p, l, q»

Задача: функция total(rows) суммирует расходы по категориям из списка словарей; перед вызовом стоит breakpoint(). Пройдите сессию отладчика — код преподаватель пишет с нуля.

  1. l — листинг вокруг текущей строки;
  2. n — next: следующая строка, не входя в функции;
  3. p rows — напечатать список rows;
  4. s — step: войти в total;
  5. p result — напечатать result на первом витке цикла;
  6. c — continue → вывод {'food': 1500, 'fun': 2000}.

Намеренно показать 1 ошибку: p r до входа в цикл → NameError — разобрать: переменная ещё не создана.

После блока — 1–2 минуты: поставьте breakpoint() в свою функцию и пройдите n/p.

«print-дебаг vs breakpoint(): когда что использовать»

Критерийprint-дебагbreakpoint()/pdb
Скорость догадкибыстрыйчуть медленнее (остановка)
Правка кодатребует вставки и удаления строкне требует правки логики
Состояние в момент ошибкине показывает (только после print)показывает в момент останова
Где применимодноразовые скрипты, быстрые проверкисложные цепочки вызовов, циклы

Правило: print годится для быстрых догадок; breakpoint() — когда нужно состояние в момент ошибки; print, оставленный в коде, — замусоривание вывода.

Когда print-дебаг ещё допустим? (одноразовый скрипт)

«Команды pdb: n next, s step, c continue, p print, l list, q quit»

> pdb_demo.py(10)<module>() -> print(total(rows)) (Pdb) l # листинг вокруг текущей строки (Pdb) n # next: следующая строка, не входя в функции (Pdb) s # step: войти в функцию (Pdb) p rows # напечатать выражение [{'category': 'food', 'sum': 1500}, {'category': 'fun', 'sum': 2000}] (Pdb) c # continue: до следующей точки останова (Pdb) q # quit: выйти из отладчика
Команды повторяются нажатием Enter — с какой команды вы начнёте на семинаре?

«Логирование вместо print: logging с уровнями DEBUG/INFO/WARNING/ERROR»

import logging logging.basicConfig(level=logging.INFO) logging.debug("детали отладки") logging.info("старт работы") logging.warning("внимание: файл не найден") logging.error("ошибка: не удалось прочитать") INFO:root:старт работы WARNING:root:внимание: файл не найден ERROR:root:ошибка: не удалось прочитать

Правило: уровни DEBUG/INFO/WARNING/ERROR; debug не выводится при уровне INFO — выключение без правки кода; можно писать в файл и консоль; print допустим только в одноразовом скрипте.

Почему print-дебаг в CLI-утилите — плохо? (замусоривает вывод, выключить нельзя без правки кода)

«Стоп-вопрос: в чём разница между print-дебагом и breakpoint()

  1. В чём разница между print-дебагом и breakpoint()? Когда что использовать?
  2. Что вы узнаёте из полного traceback, если читаете его целиком, а не только последнюю строку?

Эталон: print — быстро, но требует правки кода и не показывает состояние в момент ошибки; breakpoint — остановка с полным состоянием; полный traceback — цепочку вызовов и первопричину.

Пауза 10–15 секунд; затем — переход к тестам как диагностике.

«Тест как диагностика: тест-на-баг, который падает ДО фикса»

import pytest def add_transaction(transactions, item): transactions.append(item) return transactions def test_negative_amount_raises(): with pytest.raises(ValueError): add_transaction([], {"sum": -100})

Состояние ДО фикса ✓: тест падаетadd_transaction принимает отрицательную сумму без ошибки, pytest.raises(ValueError) не срабатывает.

Правило: написать тест, воспроизводящий баг → убедиться, что он падает (это и есть воспроизведение) → починить → тест зелёный.

Почему тест должен падать ДО фикса? (иначе неизвестно, что он ловит именно этот баг)

«Фикс: тест, падавший на баге, — зелёный; фикс без теста — не доказан»

def add_transaction(transactions, item): if item["sum"] < 0: raise ValueError("сумма не может быть отрицательной") transactions.append(item) return transactions

Состояние ПОСЛЕ фикса ✓: test_negative_amount_raisesзелёный (pytest.raises(ValueError) срабатывает).

Правило: тест, который не падал на баге, бесполезен; фикс без теста — не доказан («исправил, вроде работает» — через месяц баг вернулся).

Что доказывает зелёный тест после фикса? (баг воспроизводился и больше не воспроизводится)

«Тест не падал на баге — бесполезен; тест падал — это и есть воспроизведение»

ТестСтатусЧто означает
падал на баге, зелёный после фиксаполезныйбаг воспроизведён и устранён, регрессия поймана
не падал на баге (писался «на всякий случай»)бесполезныйнеизвестно, ловит ли тест этот баг
фикс без тестане доказан«исправил, вроде работает» — баг может вернуться

Правило-вывод: тест-на-баг — это диагностика в форме проверки; «красный → зелёный» — интуитивный задел на TDD.

Как проверить, что тест полезен? (удалить фикс — тест должен упасть)

«Задел на TDD: полный цикл red-green-refactor — модуль 5 трека»

Что общего между тестом-на-баг и TDD? (тест первичен, зелёный — критерий)

«Так нельзя: правка наугад в "проде" — без минимального примера»

# антипример: код с багом def total_by_category(rows, limit): result = {} for r in rows: if r["sum"] > limit: cat = r["category"] result[cat] = result.get(cat, 0) + r["sum"] return result
  1. «Как не надо»: правка наугад — меняем > на >= без гипотезы; на одних данных «прошло», на других (граница limit) — поведение изменилось, и баг «плавает».
  2. Воспроизвести: взять баг из Слайда 3 (first_n) — нужен конкретный баг, а не «иногда бывает».
  3. Гипотеза: off-by-one в range(n - 1).
  4. Эксперимент: range(n) на n = 5 и n = 0.
  5. Фикс: return list(range(n)).
  6. Тест зелёный — баг воспроизведён и устранён.

Намеренная ошибка недели: правка наугад без гипотезы и без минимального примера. После блока — 1 минута: «напишите тест-на-баг для first_n(5)».

Правка наугад — лотерея: вы починили один вход и сломали другой. Чем полный цикл метода отличается от неё?

«Шесть ошибок недели 14, которые вы совершите на семинаре»

  1. правка «наугад» без гипотезы — код меняется, баг «плавает»;
  2. игнорирование сообщения об ошибке — читают только «Traceback» и ищут «где-то рядом», хотя сообщение называет точную причину;
  3. отладка «в проде» — правят боевой код, не сделав минимальный воспроизводимый пример;
  4. отсутствие воспроизводимого примера — «баг появляется иногда», метод не работает;
  5. фикс без теста — «исправил, вроде работает», через месяц баг вернулся;
  6. print-дебаг оставлен в коде — замусоривают вывод утилиты.
Какая ошибка дороже всего в эксплуатации сервиса? (№ 5 — фикс без теста: регрессия возвращается)

«Проверь себя: 4 вопроса перед семинаром»

  1. Почему первый шаг отладки — воспроизведение, а не исправление?
  2. Что вы узнаёте из полного traceback, если читаете его целиком, а не только последнюю строку?
  3. В чём разница между print-дебагом и breakpoint()? Когда что использовать?
  4. Почему тест, который не падал на баге, бесполезен?

2 минуты письменно, затем короткий разбор (эталоны — в решения-14.md).

Это сигнал для семинара: что разобрать подробнее.

«Проект модуля: проверка поведения, отчёт, Git (ветка refactor-m1, PR)»

Что доказывает проект модуля целиком? (код можно читать, менять и проверять)

«Сегодня вы научились…»

Всё, что обещали в начале, — сделали? (связать с целями Слайда 4)

«One-minute paper: главное + один вопрос»

Это сигнал для семинара: что разобрать подробнее.

«Что дальше: семинар недели 14, ДЗ test_bug.py, сдача проекта модуля»

Готовы? Откройте семинар-14.md, задачи 3.1–3.5 — по своему уровню.