К содержанию

Модуль 8 · Урок 8.3

Читаемость, аннотации и тесты

Понятный код, аннотации типов, модульные тесты, медленный эталон и проверка свойств.

4 задачи

Дополнительный урок — для продвинутых. Он для тех, кто пишет программы, которые нужно поддерживать и проверять: в темах ЕГЭ аннотации и тесты не нужны, но приём «быстрое решение против медленного эталона» полезен и на экзамене. Задачи основных уроков решаются без него.

Программу из урока 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]))
2

После параметра стоит ожидаемый тип, после -> — тип результата. 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([]))
-2 None

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()
.... ---------------------------------------------------------------------- Ran 4 tests in 0.000s OK

Класс тестов наследует unittest.TestCase, как Task наследовал NamedItem в уроке о классах. Каждый метод с именем на test_ — отдельный тест. assertEqual(a, b) требует равенства, assertIsNone — значения None. Точка в выводе — пройденный тест, OK — все прошли; время прогона у тебя может быть другим. Запуск из терминала:

python test_summary.py

Тесты нужны ради момента, когда что-то сломалось. Допустим, при правке в summarize написали // вместо /:

        "average": total // len(numbers),
.F.. ====================================================================== FAIL: test_fractional_average (__main__.SummarizeTests.test_fractional_average) ---------------------------------------------------------------------- Traceback (most recent call last): File "test_summary.py", line 19, in test_fractional_average self.assertEqual(summarize([1, 2])["average"], 1.5) AssertionError: 1 != 1.5 ---------------------------------------------------------------------- Ran 4 tests in 0.000s FAILED (failures=1)

Вторая точка стала 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 случайных числах")
совпадает на 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))
True True

Однако отдельное свойство может пропустить ошибку: список [1, n] содержит только настоящие делители, но не все. Поэтому проверяй и допустимость найденного, и полноту решения.

Итог

Читаемый код объясняет себя, а тесты проверяют его после каждой правки — быстрее и надёжнее ручных запусков. Вот что понадобится дальше.

  • ЧитаемостьИмена и константы говорят о смысле, комментарии — о причинах.
  • Аннотацииlist[int], int | None описывают договорённость; Python их не проверяет.
  • unittestМетоды test_… с assertEqual; точка — прошёл, F — упал.
  • ЭталонБыстрое решение сверяют с медленным на случайных данных с фиксированным генератором.
  • СвойстваПроверяют и допустимость ответа, и полноту: одно свойство может пропустить ошибку.

Проверьте себя

Ответь сначала сам, потом открой ответ.

Вопрос 1

Достаточно ли теста is_prime(7) == True?

ОтветСкрыть ответ
Ответ

Нет: функция, всегда возвращающая True, его пройдёт. Нужны хотя бы простое 2, обычное простое, составное чётное, составное нечётное, точный квадрат вроде 49, а также 0, 1 и отрицательное число, если функция обещает их обрабатывать.

Вопрос 2

Прогон четырёх тестов начался строкой .F... Что это значит?

ОтветСкрыть ответ
Ответ

Второй тест упал, остальные три прошли. Ниже в отчёте — имя упавшего теста и какие значения не совпали.

Вопрос 3

Почему эталон для проверки быстрого решения не должен повторять ту же хитрую логику?

ОтветСкрыть ответ
Ответ

Иначе обе функции ошибутся одинаково и проверка ничего не покажет.

Вопрос 4

Почему у генератора случайных чисел в тестах фиксируют начальное состояние?

ОтветСкрыть ответ
Ответ

Чтобы найденную ошибку можно было воспроизвести тем же набором чисел.

Вопрос 5

Какие свойства результата проверить у функции, которая возвращает список делителей числа?

ОтветСкрыть ответ
Ответ

Каждый элемент делит число без остатка, есть 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]))

Введите число.

Что напечатает программа?

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]))

Введите число.

Что напечатает программа?

def twice(x: int) -> int:    return x * 2 print(twice("ab"))

Введите то, что напечатала программа.

Напиши функцию и тесты: последняя цифра

Напиши функцию last_digit(n) — последняя цифра модуля числа — и три теста unittest: для обычного числа, отрицательного и нуля. Напечатай last_digit(47), last_digit(-123) и last_digit(0) одним print, запусти и запиши, что напечатала программа.

Запишите три цифры через пробел в порядке вызовов.