Модуль 8 · Урок 8.3
Читаемость, аннотации и тесты
Понятный код, аннотации типов, модульные тесты, медленный эталон и проверка свойств.
Дополнительный урок — для продвинутых. Он для тех, кто пишет программы, которые нужно поддерживать и проверять: в темах ЕГЭ аннотации и тесты не нужны, но приём «быстрое решение против медленного эталона» полезен и на экзамене. Задачи основных уроков решаются без него.
Программу из урока 7.1 ты проверял руками: запускал на обычном, пустом и испорченном файле и сверял вывод. После каждой правки всё это пришлось бы повторять. Код пишут так, чтобы его было легко прочитать и проверить: понятные имена, аннотации договорённости и тесты, которые проверяют программу за тебя.
Читаемость
Код должен объяснять задачу именами, константами и комментариями о причинах. Ясные имена, короткие функции и явные условия обычно полезнее сокращения числа строк. Имя count говорит о количестве, total — о сумме, best — о выбранном лучшем результате. Если рядом существуют максимум значения и максимум суммы пары, назови их по-разному.
Избегай магических чисел — необъяснённых констант. Если 86400 обозначает число секунд в сутках, имя SECONDS_PER_DAY делает смысл видимым. Заглавные буквы — соглашение о постоянном значении; Python не запрещает его изменить.
Комментарий должен объяснять решение, ограничение или причину. Комментарий «увеличиваем i» рядом с i += 1 обычно менее полезен, чем «левая граница больше не может дать допустимое окно».
Аннотации типов
Аннотация типа описывает ожидаемые данные, но Python её не проверяет. Её пишут после имени параметра и после ->:
def count_positive(numbers: list[int]) -> int: return sum(number > 0 for number in numbers) print(count_positive([3, -1, 0, 5]))После параметра стоит ожидаемый тип, после -> — тип результата. list[int] означает список целых. Логические значения в арифметике ведут себя как 0 и 1, поэтому сумма истинных сравнений считает их количество.
Python не проверяет эти аннотации при вызове. Для анализа без запуска используют отдельные инструменты проверки типов. Они помогают находить несоответствия, но не доказывают математическую правильность алгоритма.
def maximum_negative(numbers: list[int]) -> int | None: best = None for number in numbers: if number < 0 and (best is None or number > best): best = number return best print(maximum_negative([5, -9, -2, 0]), maximum_negative([]))int | None в Python 3.10+ означает один из двух вариантов результата. Это помогает помнить про отсутствие ответа.
Тонкости:не только список
Если функция принимает любой перебираемый источник — список, кортеж, генератор, — точнее аннотация Iterable[int] из модуля collections.abc.
Аннотация — часть описания договорённости. Проверку диапазона, запрет пустого входа и изменение аргументов часто надо описать отдельно в документации.
Модульные тесты
Модульный тест — программа, которая вызывает функцию и сравнивает результат с ожидаемым. Проверим summarize из урока 7.1. Тесты лежат в отдельном файле рядом с analyze_numbers.py и импортируют функцию, как check.py в том уроке:
import unittest from analyze_numbers import summarize class SummarizeTests(unittest.TestCase): def test_usual_numbers(self): report = summarize([5, -2, 10, 7]) self.assertEqual(report["total"], 20) self.assertEqual(report["maximum"], 10) def test_one_number(self): self.assertEqual(summarize([-5])["average"], -5.0) def test_empty_list(self): self.assertIsNone(summarize([])["maximum"]) def test_fractional_average(self): self.assertEqual(summarize([1, 2])["average"], 1.5) if __name__ == "__main__": unittest.main()Класс тестов наследует unittest.TestCase, как Task наследовал NamedItem в уроке о классах. Каждый метод с именем на test_ — отдельный тест. assertEqual(a, b) требует равенства, assertIsNone — значения None. Точка в выводе — пройденный тест, OK — все прошли; время прогона у тебя может быть другим. Запуск из терминала:
python test_summary.pyТесты нужны ради момента, когда что-то сломалось. Допустим, при правке в summarize написали // вместо /:
"average": total // len(numbers),Вторая точка стала F: тест test_fractional_average упал. Ниже — строка теста и что не совпало: получили 1, ждали 1.5. Остальные три теста прошли — для них целое деление случайно дало верный ответ. Поэтому тесты подбирают так, чтобы каждый ловил свою ошибку, как в уроке об исключениях.
Тонкости:assertEqual и assert
Методы assertEqual и другие — обычные методы библиотеки: в отличие от инструкции assert, они не отключаются при запуске python -O и показывают оба значения при расхождении.
Медленный эталон
Быстрое решение проверяют медленным, но очевидным эталоном. Оптимизированный алгоритм может быть трудно проверить глазами. Для маленьких данных напиши очевидный полный перебор, затем сравнивай результаты. Медленный вариант здесь служит независимым образцом, поэтому желательно, чтобы он не повторял ту же самую хитрую логику.
Например, сумму цифр можно считать арифметикой, а эталон — прямо через запись числа:
import random def digit_sum(number): number = abs(number) total = 0 while number > 0: total += number % 10 number //= 10 return total def digit_sum_slow(number): return sum(int(c) for c in str(abs(number))) rng = random.Random(42)for _ in range(1000): n = rng.randint(-10**6, 10**6) assert digit_sum(n) == digit_sum_slow(n), nprint("совпадает на 1000 случайных числах")Договорённость — целые числа любого знака.
n идёт в обе функции. Пока ответы равны — следующее число; при расхождении assert останавливает программу и показывает n.При случайной генерации используй фиксированное начальное состояние генератора, например random.Random(42), чтобы ошибку можно было воспроизвести. Случайные тесты дополняют, а не заменяют специально выбранные границы: число 0 случайно может и не выпасть.
Проверка свойств
Иногда проверяют не ответ, а свойство, которое ему обязано принадлежать. После сортировки должны сохраниться количество и набор элементов с учётом повторов. Для перевода основания int(запись, base) от записи числа должно давать исходное число. Каждый найденный делитель обязан делить число без остатка, а список делителей должен содержать 1 и само положительное число.
from collections import Counter data = [5, 3, 5, -1, 0]result = sorted(data)ordered = all(a <= b for a, b in zip(result, result[1:]))print(ordered, Counter(result) == Counter(data))Однако отдельное свойство может пропустить ошибку: список [1, n] содержит только настоящие делители, но не все. Поэтому проверяй и допустимость найденного, и полноту решения.
Итог
Читаемый код объясняет себя, а тесты проверяют его после каждой правки — быстрее и надёжнее ручных запусков. Вот что понадобится дальше.
- ЧитаемостьИмена и константы говорят о смысле, комментарии — о причинах.
- Аннотации
list[int],int | Noneописывают договорённость; Python их не проверяет. unittestМетодыtest_…сassertEqual; точка — прошёл,F— упал.- ЭталонБыстрое решение сверяют с медленным на случайных данных с фиксированным генератором.
- СвойстваПроверяют и допустимость ответа, и полноту: одно свойство может пропустить ошибку.
Проверьте себя
Ответь сначала сам, потом открой ответ.
Достаточно ли теста is_prime(7) == True?
ОтветСкрыть ответ
Нет: функция, всегда возвращающая True, его пройдёт. Нужны хотя бы простое 2, обычное простое, составное чётное, составное нечётное, точный квадрат вроде 49, а также 0, 1 и отрицательное число, если функция обещает их обрабатывать.
Прогон четырёх тестов начался строкой .F... Что это значит?
ОтветСкрыть ответ
Второй тест упал, остальные три прошли. Ниже в отчёте — имя упавшего теста и какие значения не совпали.
Почему эталон для проверки быстрого решения не должен повторять ту же хитрую логику?
ОтветСкрыть ответ
Иначе обе функции ошибутся одинаково и проверка ничего не покажет.
Почему у генератора случайных чисел в тестах фиксируют начальное состояние?
ОтветСкрыть ответ
Чтобы найденную ошибку можно было воспроизвести тем же набором чисел.
Какие свойства результата проверить у функции, которая возвращает список делителей числа?
ОтветСкрыть ответ
Каждый элемент делит число без остатка, есть 1 и само число, нет повторов, и количество совпадает с полным перебором.
Практика
Для проверки ответов нужен JavaScript. Условия, подсказки и решения доступны ниже.
Что напечатает программа?
def count_positive(numbers: list[int]) -> int: return sum(number > 0 for number in numbers) print(count_positive([3, -1, 0, 5, 8]))Введите число.
Подсказка
Каждое истинное сравнение в сумме считается как 1; ноль не положителен.
Разбор
Положительные — 3, 5 и 8.
Ответ: 3
Что напечатает программа?
def maximum_negative(numbers: list[int]) -> int | None: best = None for number in numbers: if number < 0 and (best is None or number > best): best = number return best print(maximum_negative([-5, -5, 2]))Введите число.
Подсказка
Отрицательное здесь одно значение, хоть и дважды; 2 не подходит.
Разбор
Оба отрицательных числа равны −5, поэтому наибольшее отрицательное — −5.
Ответ: -5
Что напечатает программа?
def twice(x: int) -> int: return x * 2 print(twice("ab"))Введите то, что напечатала программа.
Подсказка
Python не проверяет аннотации при вызове; строку тоже можно умножить на 2.
Разбор
Аннотация int — только описание. "ab" * 2 — строка abab.
Ответ: abab
Напиши функцию и тесты: последняя цифра
Напиши функцию last_digit(n) — последняя цифра модуля числа — и три теста unittest: для обычного числа, отрицательного и нуля. Напечатай last_digit(47), last_digit(-123) и last_digit(0) одним print, запусти и запиши, что напечатала программа.
Запишите три цифры через пробел в порядке вызовов.
Подсказка
abs(n) % 10; без abs для −123 получилось бы 7.
Разбор
Например:
import unittest def last_digit(n): return abs(n) % 10 class LastDigitTests(unittest.TestCase): def test_positive(self): self.assertEqual(last_digit(47), 7) def test_negative(self): self.assertEqual(last_digit(-123), 3) def test_zero(self): self.assertEqual(last_digit(0), 0) print(last_digit(47), last_digit(-123), last_digit(0))Тесты запускают отдельно: unittest.main() в конце файла или python -m unittest.
Ответ: 7 3 0