Функции в Python
Функции как объекты, аргументы, области видимости и замыкания, функциональный стиль, декораторы и модуль functools
Вячеслав Федоров
Лекция 5
Разработка и применение ПО
в физических исследованиях
Изложение. Пятая лекция посвящена функциям — основному средству, которым программа на Python делится на части. Рассматриваются функция как объект, способы передачи аргументов, правила поиска имён и замыкания, приёмы функционального стиля, декораторы, добавляющие функции поведение без изменения её тела, и модуль functools. Надпись на титуле — ключевое слово def, с которого начинается объявление функции.
Демонстрации. Слайды с розовым выделением слова «Демонстрация» отмечают места, где изложение прерывается для работы в интерактивной оболочке или терминале виртуальной машины lab. На каждом таком слайде проигрывается запись тех же команд, поэтому показ воспроизводим и без живого терминала.
Содержание
От объявления функции до декораторов
Изложение. Лекция состоит из шести частей. Первая рассматривает функцию как объект: объявление, документацию, атрибуты и аннотации типов. Вторая посвящена аргументам: видам параметров, значениям по умолчанию, упаковке и распаковке. Третья — областям видимости: правилу LEGB, инструкциям global и nonlocal, замыканиям и позднему связыванию. Четвёртая — функциональному стилю: лямбдам, функциям высшего порядка, чистым функциям, передаче параметров модели в SciPy и стоимости вызова. Пятая — декораторам, шестая — рекурсии и модулю functools.
Цель лекции
Функция как единица расчёта: повторное использование, проверка, воспроизводимость
Функция является первым инструментом борьбы со сложностью: фрагмент логики, скрытый за именем, в дальнейшем не требуется удерживать в памяти. Расчёт, разбитый на функции с явными параметрами, проверяется по частям и воспроизводится с теми же входными данными.
Изложение. Функции решают четыре задачи. Во-первых, повторное использование: формула, записанная один раз, вызывается из многих мест, и исправление ошибки в ней действует во всех местах вызова. Во-вторых, проверка: функцию с явными входами и выходом можно проверить тестом отдельно от остальной программы. В-третьих, воспроизводимость: результат функции, зависящей только от аргументов, повторяется при тех же входных данных, а скрытое глобальное состояние эту связь разрушает. В-четвёртых, композиция: функции передаются другим функциям — интеграторам, оптимизаторам, сортировке — как аргументы.
Для физиков: подынтегральное выражение для scipy.integrate.quad, целевая функция для scipy.optimize.minimize и модель для curve_fit передаются именно как функции; понимание того, как функции принимают параметры и захватывают значения, определяет, правильно ли будет выполнен такой расчёт.
Функция как объект
Объявление, документация, атрибуты, аннотации типов
01
Функция как объект
Аргументы
Области видимости
Функциональный стиль
Декораторы
Модуль functools
Изложение. Первая часть показывает, что функция в Python — такой же объект, как число или список: её можно связать с другим именем, положить в словарь, передать в аргументе и исследовать её атрибуты.
Объявление
функции
def kinetic_energy(m, v): """Кинетическая энергия тела, Дж. m — масса, кг; v — скорость, м/с. """ return m * v ** 2 / 2 kinetic_energy(2.0, 3.0) # 9.0
Элемент Назначение
def создание объекта функции и связывание имени
return возврат значения; без return — None
строка документации строковый литерал в начале тела: __doc__, help()
имя буквы, цифры и _, не с цифры; snake_case (PEP 8)
Пояснение
def выполняется во время работы программы, как присваивание: функцию можно определить в условии или внутри другой функции.
Изложение. Функция объявляется инструкцией def: за ключевым словом следуют имя, список параметров в скобках, двоеточие и тело с отступом. Инструкция return завершает функцию и возвращает значение; функция, дошедшая до конца тела без return, возвращает None. Первая инструкция тела, если это строковый литерал, становится строкой документации: она доступна в атрибуте __doc__ и выводится функцией help. По соглашению PEP 257 первая строка документации — краткое описание в одно предложение, затем пустая строка и подробности, кавычки тройные даже у однострочной записи.
def — не объявление в смысле компилируемых языков, а инструкция, выполняемая во время работы программы: она создаёт объект функции и связывает его с именем, как присваивание. Поэтому функцию можно определить внутри условия, цикла или другой функции, а повторное выполнение def создаёт новый объект.
Для физиков: в строке документации расчётной функции указываются единицы измерения аргументов и результата; это простейшая мера против ошибки в порядке величины.
Функция —
объект первого класса
01
Связывание с именами
circle = area — второе имя того же объекта
02
Хранение в коллекциях
ops = {'area': area, 'abs': abs}
03
Передача в аргументах
sorted(data, key=f), quad(f, 0, 1)
04
Возврат из функций
фабрики функций и замыкания
Для физика
Подынтегральное выражение для quad и целевая функция для minimize передаются как аргументы: интегратору безразлично, как функция устроена внутри.
Атрибут Содержимое
__name__, __qualname__ имя и полное имя
__doc__ строка документации
__defaults__, __kwdefaults__ значения по умолчанию
__code__ объект кода: байт-код, имена
__annotations__ аннотации параметров
__closure__ ячейки замыкания
__globals__ пространство имён модуля
Изложение. Функции в Python — объекты первого класса: с ними можно делать всё то же, что с любыми другими объектами. Функцию можно связать со вторым именем, и оба имени будут ссылаться на один объект. Её можно положить в список или словарь — например, таблицу обработчиков, выбираемых по имени команды. Её можно передать в аргументе другой функции: sorted принимает функцию-ключ, интеграторы и оптимизаторы — вычисляемую функцию. Наконец, функцию можно вернуть из другой функции, на чём построены фабрики функций, замыкания и декораторы.
Объект функции класса function хранит атрибуты: имя __name__ и полное имя __qualname__ (с учётом вложенности), строку документации, значения параметров по умолчанию в __defaults__ и __kwdefaults__, объект кода __code__ с байт-кодом и именами локальных переменных, аннотации, ячейки замыкания и ссылку на словарь глобальных имён модуля, в котором функция определена.
Для физиков: scipy.integrate.quad(f, a, b) вызывает переданную функцию f в выбранных им точках; интегратору безразлично, вычисляет ли f формулу, интерполирует таблицу или сама вызывает другой интеграл.
Аннотации
типов
def mean(xs: list[float], w: list[float] | None = None) -> float: ... mean.__annotations__ # {'xs': list[float], 'w': list[float] | None, 'return': <class 'float'>}
Источник Возможность
PEP 3107, 3.0 синтаксис аннотаций параметров и результата
PEP 484, 3.5 модуль typing; на нём работают mypy и pyright
PEP 585, 3.9 list[float], dict[str, int] без typing
PEP 604, 3.10 объединение типов: float | None
PEP 649/749, 3.14 аннотации вычисляются отложенно
Важно
Аннотации не проверяются при выполнении: аргумент другого типа принимается, и TypeError, если возникнет, произойдёт в теле функции. Проверку выполняют анализаторы mypy и pyright.
Изложение. Параметры и результат функции можно снабдить аннотациями: после имени параметра через двоеточие указывается тип, после списка параметров через стрелку — тип результата. Синтаксис появился в Python 3.0 (PEP 3107), смысл аннотаций как подсказок типов закрепил PEP 484 вместе с модулем typing. С 3.9 встроенные коллекции указываются как list[float] без импорта из typing, с 3.10 объединение типов записывается через вертикальную черту: float | None. В Python 3.14 аннотации вычисляются отложенно, при первом обращении (PEP 649 и 749).
Интерпретатор аннотации только сохраняет в атрибуте __annotations__ и не проверяет: вызов с аргументом другого типа выполняется, и ошибка, если она будет, возникнет внутри тела функции. Соответствие типов проверяют статические анализаторы mypy и pyright, а редакторы кода используют аннотации для подсказок.
Для физиков: для массивов NumPy предусмотрен тип numpy.typing.NDArray; единицы измерения аннотации не выражают, их по-прежнему указывают в документации.
Демонстрация:
функция как объект
01
Функция с аннотациями и документацией
02
Имя и строка документации
03
Аннотации в __annotations__
04
Второе имя для той же функции
05
Функции как значения словаря
07
Непроверяемая аннотация: area('2')
Что наблюдается
Функция — объект класса function с атрибутами; аннотации хранятся, но не проверяются, и ошибка возникает внутри тела.
root@lab — запись со стенда
Демонстрация. В оболочке определяется функция area с аннотациями r: float и -> float и строкой документации. area.__name__ и area.__doc__ возвращают имя и документацию, area.__annotations__ — словарь {'r': <class 'float'>, 'return': <class 'float'>}. Имя circle связывается с той же функцией: circle(2.0) даёт 12.56636, circle is area — True, тип объекта — класс function. Словарь ops хранит пользовательскую и встроенную функции, и вызов по ключу работает для обеих: (3.14159, 3). Функция nothing без return возвращает None. Вызов area('2') не отвергается аннотацией: ошибка TypeError возникает в теле функции, при возведении строки в степень.
Документация
и doctest
def kinetic_energy(m, v): """Кинетическая энергия тела. Parameters ---------- m : float Масса, кг. v : float Скорость, м/с. Returns ------- float Энергия, Дж. Examples -------- >>> kinetic_energy(2.0, 3.0) 9.0 """
Раздел Содержимое
Parameters имя : тип, смысл, единицы
Returns тип и смысл результата
Raises исключения и их условия
Examples вызовы с ожидаемым выводом
Пояснение
python3 -m doctest phys.py выполняет примеры из документации и сравнивает вывод: документация проверяется как тест.
Для физика
Формат NumPy принят в NumPy, SciPy и Astropy; Sphinx с расширением numpydoc собирает из него справочник.
Изложение. Для научного кода принят формат строк документации NumPy: после краткого описания следуют разделы Parameters (имя, тип, смысл и единицы каждого параметра), Returns (тип и смысл результата), Raises (исключения) и Examples (вызовы с ожидаемым выводом в формате интерактивной оболочки). Так документированы NumPy, SciPy и Astropy, а генератор документации Sphinx с расширением numpydoc собирает из таких строк справочник.
Раздел Examples проверяется автоматически: модуль doctest находит в строках документации фрагменты, начинающиеся с >>>, выполняет их и сравнивает вывод с записанным. Команда python3 -m doctest phys.py молчит, если все примеры сошлись, и печатает расхождения, если нет; ключ -v выводит подробный отчёт. Вывод сравнивается как текст, поэтому для вещественных результатов подбирают точно представимые ответы, такие как 9.0 и 0.5, или печатают round(…); расхождение документации с кодом обнаруживается при очередном запуске doctest.
Для физиков: пример с известным ответом — кинетическая энергия тела массой 2 кг при скорости 3 м/с равна 9 Дж — одновременно документирует функцию и проверяет её формулу.
Демонстрация:
doctest
01
Примеры в документации функции
02
doctest: обнаружение ошибки в формуле
04
Повторный запуск с -v без ошибок
Что наблюдается
Пример ожидал 9.0, а функция без деления на 2 вернула 18.0; после исправления оба примера проходят.
root@lab — запись со стенда
Демонстрация. sed выводит раздел Examples функции kinetic_energy из файла phys.py: два вызова с ожидаемыми результатами 9.0 и 0.5. В самой функции намеренно допущена ошибка — нет деления на два. python3 -m doctest phys.py сообщает о двух неудачных примерах: ожидалось 9.0, получено 18.0; ожидалось 0.5, получено 1.0. После исправления формулы командой sed запуск doctest с ключом -v заканчивается строками «2 passed and 0 failed» и «Test passed».
Аргументы
Виды параметров, значения по умолчанию, *args и **kwargs, распаковка
02
Функция как объект
Аргументы
Области видимости
Функциональный стиль
Декораторы
Модуль functools
Изложение. Вторая часть посвящена передаче аргументов: какими бывают параметры функции, как задаются значения по умолчанию, как функция принимает произвольное число аргументов и как коллекция раскладывается по параметрам при вызове и при присваивании.
Виды
параметров
def f(a, b, /, c, *args, d=1, **kw):
a, b, /
только позиционные (3.8)
c
позиционный или именованный
*args
лишние позиционные → кортеж
**kw
лишние именованные → словарь
Вызов Связывание параметров
f(1, 2, 3) a=1, b=2, c=3, args=(), d=1, kw={}
f(1, 2, 3, 4, 5, d=6, e=7) args=(4, 5), d=6, kw={'e': 7}
f(1, 2, c=3) c передан по имени
f(*(1, 2), **{'c': 3, 'd': 4}) распаковка: a=1, b=2, c=3, d=4
Изложение. Сигнатура на слайде содержит все виды параметров в том порядке, в каком они обязаны следовать. Параметры до косой черты передаются только по позиции (PEP 570, Python 3.8): их имена не являются частью интерфейса и могут меняться без последствий для вызывающего кода; так объявлены многие встроенные функции, например len(obj, /). Параметр c можно передать и по позиции, и по имени. *args собирает все лишние позиционные аргументы в кортеж. Параметры после *args (или после одиночной звёздочки) передаются только по имени. **kw собирает все именованные аргументы, не совпавшие с именами параметров, в словарь.
При вызове позиционные аргументы следуют раньше именованных: запись f(c=3, 4) является синтаксической ошибкой: грамматика запрещает позиционный аргумент после именованного, поскольку по такой записи нельзя определить, к какому параметру он относится. Именованные аргументы можно перечислять в любом порядке. Таблица показывает связывание для нескольких вызовов; последний раскладывает кортеж и словарь по параметрам звёздочками.
Значения
по умолчанию
def integrate(f, a, b, *, n=1000, tol=1e-8): ... integrate(g, 0.0, 1.0, n=10_000) # явно integrate(g, 0.0, 1.0, 10_000) # TypeError def unique(xs, seen=None): seen = set() if seen is None else set(seen) ...
Важно
Значение по умолчанию вычисляется один раз, при выполнении def: изменяемый объект становится общим для всех вызовов. Вместо него указывается None.
Для физика
Настройки расчёта — число узлов, метод, допуск — объявляются только именованными: по вызову integrate(g, 0, 1, 1e-6, 100) не понять, где допуск, а где число узлов.
Изложение. Параметр со значением по умолчанию можно не передавать. Значение вычисляется один раз — когда интерпретатор выполняет инструкцию def, — и хранится в атрибуте __defaults__, а у только именованных параметров, как n и tol на слайде, — в __kwdefaults__. Для чисел и строк это незаметно, а изменяемый объект становится общим для всех вызовов и накапливает состояние: функция unique(iterable, seen=set()) при повторном вызове на тех же данных вернёт пустой список, поскольку множество seen уже содержит все значения. Поэтому по умолчанию указывается None, а новый объект создаётся внутри функции при каждом вызове; ловушка рассматривалась в предыдущей лекции.
Параметры, стоящие после звёздочки, передаются только по имени (PEP 3102). Так оформляются настройки со значениями по умолчанию: вызывающий код меняет их явно, и порядок настроек не имеет значения.
Для физиков: вызов integrate(g, 0, 1, 1e-6, 100) нельзя прочитать без документации и легко перепутать допуск с числом узлов; при объявлении настроек только именованными такой вызов завершается TypeError, а правильная запись integrate(g, 0, 1, tol=1e-6, n=100) объясняет себя сама.
*args
и **kwargs
def min_of(first, *rest): res = first for x in rest: if x < res: res = x return res min_of(-5, 12, 13) # -5 min_of(*{-5, 12, 13}) # распаковка
def run(cmd, **options): if options.get('verbose', True): print('Logging enabled') opts = {'verbose': False} run('mysqld', **opts)
Пояснение
Звёздочка в объявлении собирает аргументы, в вызове — раскладывает коллекцию по параметрам. Обёртка def w(*args, **kwargs): return f(*args, **kwargs) передаёт любые аргументы без изменений.
Изложение. Звёздочка перед именем параметра собирает все лишние позиционные аргументы в кортеж. Функция min_of принимает один и более аргументов; первый вынесен в обязательный параметр first, поэтому вызов без аргументов завершается TypeError, а не возвращает бессмысленное значение, как было бы с одним *args и начальным значением бесконечность. Две звёздочки собирают именованные аргументы, не совпавшие с параметрами, в словарь.
В вызове звёздочки работают в обратную сторону: *xs раскладывает любой итерируемый объект — список, кортеж, множество, генератор — на позиционные аргументы, **opts раскладывает словарь на именованные. Отсюда универсальная обёртка def w(*args, **kwargs): return f(*args, **kwargs), принимающая и передающая дальше любые аргументы; на ней построены декораторы пятой части.
Имена args и kwargs — соглашение, а не требование языка: значение имеют только звёздочки.
Демонстрация:
аргументы
01
Функция со всеми видами параметров
02
Вызов с минимумом аргументов
03
Лишние позиционные и именованные
04
Сигнатура через inspect.signature
05
Ошибки: лишний позиционный и позиционный по имени
06
Распаковка кортежа и словаря в вызове
Что наблюдается
Лишние позиционные собираются в кортеж, лишние именованные — в словарь; нарушение вида параметра обнаруживается при вызове.
root@lab — запись со стенда
Демонстрация. Функция f(a, b, /, c, *args, d=1, **kw) возвращает кортеж всех своих параметров. f(1, 2, 3) даёт (1, 2, 3, (), 1, {}); f(1, 2, 3, 4, 5, d=6, e=7) — (1, 2, 3, (4, 5), 6, {'e': 7}): лишние позиционные попали в args, неизвестный именованный e — в kw. inspect.signature(f) печатает сигнатуру с косой чертой и звёздочками. Для функции g(a, /, *, depth=None) вызов g(1, 2) завершается TypeError «takes 1 positional argument but 2 were given», а g(a=1) — «got some positional-only arguments passed as keyword arguments: 'a'». Последний вызов раскладывает кортеж (1, 2) и словарь {'c': 3, 'd': 4} по параметрам: (1, 2, 3, (), 4, {}).
Распаковка
при присваивании
first, *rest = range(1, 5) # 1, [2, 3, 4] first, *mid, last = 'физика' # 'ф', [...], 'а' (x1, y1), (x2, y2) = (0, 0), (4, 3) for name, *values in rows: ... points = [*old, *new] # PEP 448 config = {**defaults, **user}
Пояснение
Имя со звёздочкой получает список, возможно пустой; обычным именам значений должно хватить: a, *b, c = [42] вызывает ValueError.
Для физика
Строка файла вида «время, канал, отсчёты…» разбирается одним присваиванием: t, ch, *counts = line.split().
Изложение. Распаковка работает в любом присваивании: слева записывается структура из имён, справа — итерируемый объект, и Python раскладывает второе по первому, в том числе с вложенностью. Имя со звёздочкой (расширенная распаковка, PEP 3132) собирает всё, что не досталось обычным именам, в список; оно может стоять в начале, середине или конце и получить пустой список, а обычным именам значений должно хватить, иначе возникает ValueError. Заголовок цикла for тоже является присваиванием, поэтому распаковка работает и там.
С Python 3.5 звёздочки допускаются и в литералах (PEP 448): [*old, *new] объединяет последовательности в новый список, {**defaults, **user} — словари, причём при совпадении ключей сохраняется значение из правого словаря.
Для физиков: строка файла данных «время, номер канала, отсчёты» разбирается присваиванием t, ch, *counts = line.split(), а настройки расчёта объединяются из значений по умолчанию и пользовательских одним выражением.
Демонстрация:
распаковка
01
Первый элемент и остаток
02
Первый, середина и последний
03
Вложенная распаковка координат
04
Недостаток значений: ValueError
05
Распаковка в литералах списка и словаря
Что наблюдается
Звёздочка собирает остаток в список; если значений не хватает обычным именам, возникает ValueError.
root@lab — запись со стенда
Демонстрация. first, *rest = range(1, 5) даёт 1 и список [2, 3, 4]. Строка 'физика' раскладывается на первую букву, список средних и последнюю. Вложенная распаковка разбирает две пары координат одним присваиванием, и разность даёт (4, 3). a, *b, c = [42] завершается ValueError: not enough values to unpack (expected at least 2, got 1). Последняя строка собирает звёздочками список из range и строки, а также словарь из распакованного словаря {'a': 1} и пары 'b': 2.
Области видимости
LEGB, global и nonlocal, замыкания, позднее связывание
03
Функция как объект
Аргументы
Области видимости
Функциональный стиль
Декораторы
Модуль functools
Изложение. Третья часть отвечает на вопрос, где интерпретатор ищет имя, которое встречается в функции, и что происходит, когда функции присваивают имени значение. Отсюда следуют замыкания — функции, сохраняющие доступ к переменным завершившейся функции, — и связанная с ними ловушка позднего связывания.
Правило
LEGB
Built-in: len, print, min, sum
Global: имена модуля
Enclosing: объемлющая функция
Local: сама функция
поиск идёт изнутри наружу до первого совпадения
min = 42 # global def f(*args): min = 2 # enclosing def g(): min = 4 # local print(min) g()
Важно
Переменная с именем встроенной функции закрывает её: после len = 5 вызов len('abc') завершается ошибкой.
Изложение. Интерпретатор ищет имя в четырёх областях по порядку и останавливается на первой найденной: локальная область самой функции (Local), области объемлющих функций, если функция вложена (Enclosing), глобальная область модуля (Global) и встроенная область с функциями вроде len, print и min (Built-in).
В примере три разные переменные с именем min. Функция g печатает свою локальную 4; если удалить присваивание из g, будет напечатано 2 из объемлющей функции f, если удалить и его — глобальное 42. Встроенная функция min, последнее звено цепочки, при этом оказывается недоступна.
Поэтому переменные не называют именами встроенных функций: list, dict, sum, min, max, len, input, id, type. Ошибка проявляется далеко от места присваивания — там, где встроенную функцию попытаются вызвать, — и выглядит как «'int' object is not callable».
Присваивание
и области видимости
Действие в функции Результат
чтение имени поиск по LEGB
присваивание имени локальная переменная на всё тело функции
global x присваивание переменной модуля
nonlocal x присваивание переменной объемлющей функции
count = 0 def bump(): count += 1 # UnboundLocalError
Пояснение
Решение «локальная или нет» принимается при компиляции функции: присваивание где угодно в теле делает имя локальным во всём теле.
Для физика
Глобальное состояние делает результат функции зависимым от истории вызовов. Параметры и результаты передаются явно, счётчики и накопители оформляются замыканием или объектом.
Изложение. Читать имя можно из любой внешней области, а присваивание всегда создаёт локальную переменную. Решение принимается при компиляции функции и распространяется на всё её тело: если имя где-либо в функции получает значение, оно локально везде. Поэтому count += 1 завершается ошибкой не на присваивании, а на попытке прочитать локальную count, ещё не получившую значения; начиная с Python 3.11 сообщение звучит как «cannot access local variable 'count' where it is not associated with a value», прежний текст — «local variable 'count' referenced before assignment».
Чтобы изменить глобальную переменную, намерение объявляется явно инструкцией global. Для переменной объемлющей функции служит nonlocal (Python 3.0, PEP 3104): именно она позволяет вложенной функции присвоить новое значение переменной той функции, в которую она вложена. Многословность намеренная: функция, меняющая внешнее состояние, перестаёт быть предсказуемой.
Для физиков: функция моделирования, увеличивающая глобальный счётчик событий или читающая глобальный массив параметров, даёт разные результаты при повторных запусках в одном сеансе блокнота Jupyter; параметры передаются аргументами, результат возвращается, а накапливаемое состояние хранится в объекте.
Демонстрация:
области видимости
01
Чтение имени объемлющей функции
02
Присваивание в функции: UnboundLocalError
03
Встроенная функция, закрытая глобальным именем
04
Встроенная len после del
Что наблюдается
count += 1 читает ещё не получившую значения локальную переменную; после del len поиск снова доходит до встроенной области.
root@lab — запись со стенда
Демонстрация. Глобальная x и x объемлющей функции outer различны: inner возвращает 'объемлющая', а глобальная x по-прежнему 'глобальная'. Функция bump с count += 1 при вызове завершается UnboundLocalError: присваивание сделало count локальной во всей функции. Присваивание len = 5 закрывает встроенную функцию, и len('abc') даёт TypeError: 'int' object is not callable; после del len глобального имени больше нет, поиск доходит до встроенной области, и len('abc') возвращает 3.
Замыкания
и nonlocal
def make_counter(): count = 0 def counter(): nonlocal count count += 1 return count return counter c = make_counter() c(), c(), c() # (1, 2, 3)
ячейка count
cell_contents = 3
make_counter завершилась, а count продолжает существовать в ячейке
Для физика
Фабрика make_gaussian(mu, sigma) возвращает функцию одной переменной x с закреплёнными параметрами, пригодную для интегрирования и построения графика.
Изложение. Замыкание возникает, когда вложенная функция использует имя объемлющей функции и продолжает использовать его после того, как объемлющая функция завершилась. Функция make_counter уже вернула результат, её локальные переменные должны были исчезнуть, но count сохраняется: интерпретатор хранит её в ячейке, на которую ссылается кортеж __closure__ функции counter. Каждый вызов make_counter создаёт новую ячейку, поэтому счётчики независимы. Чтобы вложенная функция могла не только читать, но и изменять count, имя объявлено nonlocal.
Замыкание — простая альтернатива объекту с одним методом: в обычном коде состояние недоступно извне иначе как через функции, которые его разделяют, а служебный доступ даёт атрибут __closure__. На замыканиях построены декораторы пятой части.
Для физиков: фабрика make_gaussian(mu, sigma) возвращает функцию g(x), в которой параметры распределения закреплены; такая функция одной переменной передаётся в интегратор или служит для построения графика по найденным параметрам. Для подгонки curve_fit модель, наоборот, принимает подбираемые параметры аргументами: model(x, mu, sigma).
Позднее
связывание
funcs = [lambda: i for i in range(3)] [f() for f in funcs] # [2, 2, 2] funcs = [lambda i=i: i for i in range(3)] [f() for f in funcs] # [0, 1, 2] funcs = [partial(pow, 2, i) for i in range(3)] [f() for f in funcs] # [1, 2, 4]
Важно
Функция, созданная в цикле, захватывает переменную, а не её значение, и после цикла видит последнее значение.
Для физика
Список моделей или обработчиков для перебора параметров, созданный в цикле лямбдами, вычисляет всё с последним параметром без сообщения об ошибке.
Изложение. Имя, которое функция не определяет сама, разрешается не при создании функции, а в момент вызова — это позднее связывание. Вместе с замыканиями оно образует ловушку: лямбды, созданные во включении, захватывают одну и ту же переменную цикла i, а не её значения. К моменту вызова цикл завершился, i равна 2, и все три функции возвращают 2.
Значение фиксируется тремя способами. Параметр по умолчанию lambda i=i: i вычисляется при создании функции, а не при вызове. functools.partial закрепляет значения аргументов сразу. Фабрика функций make(i), возвращающая вложенную функцию, создаёт отдельную ячейку замыкания на каждый вызов.
Для физиков: перебор параметров моделей часто оформляют как список функций, построенный в цикле; с поздним связыванием все модели считаются с последним значением параметра, и ошибка видна только по совпадению результатов.
Демонстрация:
замыкания
01
Фабрика счётчиков с nonlocal
02
Три вызова одного счётчика
03
Значение в ячейке замыкания
04
Лямбды в цикле: позднее связывание
05
Фиксация значения параметром по умолчанию
Что наблюдается
Счётчик хранит состояние в ячейке замыкания; лямбды из цикла видят последнее значение i, пока его не закрепит параметр по умолчанию.
root@lab — запись со стенда
Демонстрация. make_counter возвращает вложенную функцию counter, изменяющую count через nonlocal. Три вызова c() в одной строке дают (1, 2, 3), а c.__closure__[0].cell_contents показывает 3 — значение, хранящееся в ячейке замыкания. Список лямбд, созданный включением по range(3), при вызове даёт [2, 2, 2]; тот же список с параметром по умолчанию i=i даёт [0, 1, 2].
Функциональный стиль
lambda, функции высшего порядка, включения, чистые функции, параметры для SciPy, стоимость вызова
04
Функция как объект
Аргументы
Области видимости
Функциональный стиль
Декораторы
Модуль functools
Изложение. Четвёртая часть посвящена приёмам функционального стиля, которые Python поддерживает наравне с императивными: анонимным функциям, функциям, принимающим другие функции, включениям, понятию чистой функции, передаче параметров модели в SciPy и стоимости вызова функции.
Анонимные
функции
lambda x: x ** 2 lambda a, *args, b=None, **kw: 42 sorted(events, key=lambda e: e.energy) max(data, key=lambda p: p[0])
Пояснение
PEP 8 рекомендует def вместо присваивания лямбды имени: f = lambda x: ... ничем не лучше def f(x): ..., но в трассировке ошибки теряет имя.
lambda def
тело одно выражение любые инструкции
имя в трассировке <lambda> имя функции
документация нет есть
применение короткий ключ или аргумент всё остальное
Изложение. Выражение lambda создаёт функцию без имени: после ключевого слова следуют параметры, двоеточие и одно выражение, значение которого становится результатом без return. Список параметров у лямбды такой же, как у обычной функции, со звёздочками и значениями по умолчанию, но без аннотаций. Лямбды удобны там, где функция нужна на одну строку: ключ сортировки, условие отбора, простое преобразование.
Как только логика перестаёт умещаться в одно выражение, функции дают имя через def. PEP 8 рекомендует не присваивать лямбду переменной: запись f = lambda x: ... ничем не лучше def f(x): ..., но в трассировке ошибки вместо имени будет <lambda>, и у такой функции нет строки документации.
Функции
высшего порядка
Функция Назначение
map(f, xs) применение f к каждому элементу, лениво
filter(pred, xs) отбор элементов, лениво; filter(None, xs) — истинные
zip(xs, ys) параллельный перебор до конца самой короткой
sorted, min, max (key=f) порядок и экстремумы по ключу
any, all проверка условия с досрочным выходом
operator.itemgetter, attrgetter готовые ключи без lambda
Важно
map, filter и zip возвращают итераторы: значения вычисляются по требованию и проходятся один раз — повторный list от того же map пуст.
Для физика
max(events, key=attrgetter('energy')) находит событие с наибольшей энергией за один проход без промежуточного списка.
Изложение. Функцией высшего порядка называют функцию, принимающую другую функцию в аргументе или возвращающую её. Встроенные map, filter и zip покрывают три частые операции над последовательностью: преобразование каждого элемента, отбор нужных и одновременный проход по нескольким наборам. В Python 3 все три возвращают не списки, а ленивые итераторы: значения вычисляются по мере перебора, и пройти их можно только один раз, поэтому второй list от того же объекта map даёт пустой список. zip останавливается по самой короткой последовательности; параметр strict=True (3.10) превращает несовпадение длин в ошибку.
sorted, min и max принимают функцию-ключ key; any и all проверяют условие и прекращают перебор, как только результат известен. Модуль operator содержит готовые функции для ключей: itemgetter(1) выбирает элемент по индексу или ключу, attrgetter('energy') — атрибут, что короче и быстрее соответствующих лямбд.
Для физиков: max(events, key=attrgetter('energy')) находит событие с наибольшей энергией за один проход, без сортировки и без промежуточного списка.
Включения
и map, filter
[x ** 2 for x in range(10) if x % 2] list(map(lambda x: x ** 2, filter(lambda x: x % 2, range(10)))) [x for xs in nested for x in xs] # вложенные {x % 7 for x in data} # множество {k: v for k, v in date.items() if v} # словарь
values = list(map(float, fields)) total = sum(x * x for x in data)
Пояснение
Включение читается проще комбинации map и filter с лямбдами; с готовой функцией короче запись через map: map(float, fields). Выражение-генератор в круглых скобках не строит список.
Изложение. То же, что делают map и filter, записывается включениями: [x ** 2 for x in range(10) if x % 2] читается как «x в квадрате для каждого x из range(10), если x нечётно». Эквивалент через map и filter с двумя лямбдами длиннее и хуже читается. Два for подряд разворачивают вложенную структуру в плоский список в том же порядке, что и вложенные циклы; фигурные скобки дают множество или словарь.
map остаётся уместен с готовой функцией: list(map(float, fields)) короче включения [float(s) for s in fields]. Выражение-генератор в круглых скобках вычисляет значения по одному и не строит промежуточный список: sum(x * x for x in data) суммирует квадраты, не выделяя память под список квадратов. Генераторы подробно рассматриваются в лекции об итераторах.
Демонстрация:
функциональный стиль
01
Сортировка записей: по первому элементу и по ключу
02
Максимум по ключу itemgetter
03
map — одноразовый итератор
04
filter(None, ...): только истинные
05
zip: обрезка по короткой последовательности
Что наблюдается
Второй list от того же map пуст: итератор уже пройден; zip останавливается по самой короткой последовательности.
root@lab — запись со стенда
Демонстрация. Список пар (значение, газ) сортируется без ключа — по первому элементу кортежа — и с ключом lambda p: p[1] — по названию. max с ключом itemgetter(0) возвращает пару с наибольшим значением (3.2, 'Ar'). Объект map возводит числа в квадрат лениво: первый list даёт [0, 1, 4, 9, 16], второй — пустой список. filter(None, ...) оставляет только истинные элементы [1, 'a', [2]], zip строки из трёх символов и range(10) даёт три пары. any и all с выражениями-генераторами проверяют условия по данным: (True, True).
Чистые
функции
01
Зависимость только от аргументов
одинаковые входы дают одинаковый выход
02
Нет побочных эффектов
аргументы и глобальное состояние не изменяются
03
Простота проверки и кеширования
тест пишется без подготовки окружения; результат можно запомнить
04
Параллельное выполнение
без блокировок и гонок
total = 0.0 def add_sample(x): # нечистая global total total += x def mean(xs): # чистая return sum(xs) / len(xs)
Для физика
Генератор случайных чисел передаётся в функцию моделирования аргументом: с тем же зерном default_rng(42) результат воспроизводится, хотя вызов меняет состояние генератора.
Изложение. Чистой называют функцию, результат которой зависит только от аргументов и которая не имеет побочных эффектов: не изменяет аргументы, глобальные переменные, файлы. Чистые функции проще проверять — тест не требует подготовки окружения, — их результат можно запомнить и не вычислять повторно, как делает lru_cache в шестой части, и их можно выполнять параллельно без блокировок. Функция add_sample нечистая: она меняет глобальную переменную, и её поведение зависит от истории вызовов; mean чистая.
Программа целиком из чистых функций не состоит — чтение данных и запись результатов являются побочными эффектами. Удобно держать расчётное ядро чистым, а ввод-вывод сосредоточить на его границе.
Для физиков: функция моделирования, использующая глобальный генератор случайных чисел, даёт разные результаты при каждом запуске; генератор, переданный аргументом и созданный с фиксированным зерном, делает расчёт воспроизводимым. Чистой такая функция не становится, поскольку каждый вызов меняет состояние генератора, но её результат определяется аргументами, включая генератор.
Параметры
для интеграторов
def gauss(x, mu, sigma): return math.exp(-(x - mu) ** 2 / (2 * sigma ** 2)) quad(gauss, -5, 5, args=(0.0, 1.0)) # 1: args= quad(lambda x: gauss(x, 0.0, 1.0), -5, 5) # 2: лямбда quad(partial(gauss, mu=0.0, sigma=1.0), -5, 5) # 3: partial
Способ Когда уместен
args=(...) параметр args у quad, minimize, solve_ivp
lambda одиночный вызов с закреплёнными значениями
partial передача в процессы (функция — из импортируемого модуля)
замыкание, фабрика модель с логикой и состоянием; pickle не сериализует
Важно
Лямбды, собранные в цикле и вызванные после него, получат последнее значение параметра; quad внутри той же итерации вычисляется верно.
Изложение. Интеграторы, оптимизаторы и решатели SciPy ожидают функцию одной переменной (или вектора), а модель обычно зависит ещё и от параметров. Есть четыре способа их передать. Многие функции SciPy принимают дополнительные аргументы в параметре args и передают их вызываемой функции после основных переменных (у solve_ivp — после t и y): quad(gauss, -5, 5, args=(0.0, 1.0)) вызывает gauss(x, 0.0, 1.0). Лямбда подставляет параметры при каждом вызове: литералы — как записаны, имена — по значению на момент вызова. functools.partial закрепляет значения при создании и, в отличие от лямбды, сериализуется модулем pickle, если сериализуемы исходная функция и закреплённые аргументы, что требуется при распараллеливании через multiprocessing; сама функция при этом определяется в импортируемом модуле. Замыкание или фабрика функций уместны, когда модель содержит собственную логику или состояние, но pickle их не сериализует.
Для физиков: список функций для разных sigma, построенный лямбдами в цикле и вычисляемый после него, из-за позднего связывания даст все интегралы с последним sigma; partial и фабрика такой ошибки не допускают. Вызов quad внутри той же итерации цикла вычисляется верно.
Стоимость
вызова функции
Операция над 10⁵ чисел Время (Python 3.12)
[x * x for x in xs] 1,64 мс
[sq(x) for x in xs] 2,66 мс
list(map(sq, xs)) 3,10 мс
цикл for с t += x 1,17 мс
sum(xs) 0,25 мс
Пояснение
Вызов функции Python добавил около 10 нс на элемент: 2,66 мс против 1,64 мс на 10⁵ чисел.
Для физика
Цикл по миллионам значений переносят во встроенные функции и NumPy: они выполняют цикл в скомпилированном коде. Подробно — в главе «Оптимизация средствами самого Python».
Изложение. Вызов функции в Python имеет собственную стоимость: интерпретатор создаёт кадр, связывает аргументы и возвращает результат. Замеры в Python 3.12 для 100 тысяч чисел показывают, что включение с вызовом функции-лямбды (2,66 мс) медленнее включения с тем же выражением, записанным прямо (1,64 мс), то есть вызов обходится примерно в 10 нс; map с лямбдой в этих замерах ещё медленнее (3,10 мс), хотя в других версиях соотношение бывает обратным. Явный цикл накопления суммы занимает 1,17 мс, а встроенная sum — 0,25 мс: её цикл выполняется в функции на C, без исполнения байт-кода для каждого элемента.
Это не означает, что код пишется без функций: 10 нс заметны только в цикле по миллионам элементов. Участок, на который приходится основное время, переносят во встроенные функции или в векторные операции NumPy, которые обрабатывают весь массив одним вызовом в скомпилированном коде; это рассматривается в главе книги «Оптимизация средствами самого Python».
Демонстрация:
стоимость вызова
01
Подготовка: список и функция sq
03
Включение с вызовом функции
Что наблюдается
Вызов функции добавляет около 10 нс на элемент; встроенная sum в четыре-пять раз быстрее явного цикла.
root@lab — запись со стенда
Демонстрация. Переменная оболочки S хранит подготовку: список из 100 тысяч чисел и лямбду sq. timeit с этой подготовкой измеряет пять вариантов: включение [x * x for x in xs] — около 1,6 мс, включение с вызовом sq — около 2,7 мс, list(map(sq, xs)) — около 3,1 мс, явный цикл накопления суммы — около 1,2 мс, встроенная sum — около 0,25 мс.
Декораторы
Синтаксис, functools.wraps, аргументы, цепочки, практические декораторы
05
Функция как объект
Аргументы
Области видимости
Функциональный стиль
Декораторы
Модуль functools
Изложение. Пятая часть посвящена декораторам — функциям, которые принимают функцию и возвращают новую, добавляя поведение без изменения исходного тела: измерение времени, запись вызовов, проверку аргументов, кеширование.
Декоратор
как функция
@decorator def foo(x): return 42
≡
def foo(x): return 42 foo = decorator(foo)
Пояснение
Декоратор — функция, принимающая функцию и возвращающая новую функцию или другой объект. Синтаксис @ появился в Python 2.4 (PEP 318), с 3.9 после @ допускается любое выражение (PEP 614).
Для физика
В научном коде декораторы встречаются постоянно: @numba.njit компилирует функцию, @functools.lru_cache запоминает результаты, @pytest.fixture готовит данные для тестов.
Изложение. Декоратор — это функция, которая принимает другую функцию и возвращает новую (в общем случае — любой объект). Запись @decorator перед def является синтаксическим сахаром: она не добавляет возможностей и практически эквивалентна присваиванию foo = decorator(foo) сразу после определения функции; отличие в том, что выражение декоратора вычисляется до создания функции, а имя foo ни в какой момент не связано с исходной функцией. После применения декоратора имя foo ссылается на то, что вернул декоратор. Синтаксис @ появился в Python 2.4 (PEP 318); с версии 3.9 после @ может стоять любое выражение (PEP 614), а не только имя или вызов.
Для физиков: декораторы встречаются в научном коде постоянно — @numba.njit компилирует функцию в машинный код, @functools.lru_cache запоминает результаты, @pytest.fixture объявляет подготовку данных для тестов, @dataclass (декоратор класса) порождает методы класса данных.
Устройство
простого декоратора
def trace(func): def inner(*args, **kwargs): print('вызов', func.__name__, args, kwargs) return func(*args, **kwargs) return inner @trace def area(r): return 3.14159 * r ** 2
Пояснение
func доступна внутри inner после завершения trace благодаря замыканию.
func(*args, **kwargs) → результат
Изложение. Декоратор trace получает исходную функцию в параметре func и определяет новую функцию inner. Та принимает аргументы в самом общем виде *args, **kwargs, пригодном для любой функции, печатает имя и аргументы вызова, передаёт управление исходной функции и возвращает её результат. Наружу trace возвращает inner. После @trace имя area ссылается на inner, и вызов area(2.0) проходит цепочку, показанную справа.
Исходная функция остаётся доступной внутри inner после того, как trace завершилась: это замыкание из третьей части. Каждая декорированная функция получает свою обёртку со своей ячейкой func.
functools.wraps
и метаданные
Атрибут Без wraps С wraps
__name__ 'inner' 'area'
__doc__ None документация area
help(area) справка об inner справка об area
__wrapped__ нет исходная функция
inspect.signature (*args, **kwargs) (r)
import functools def trace(func): @functools.wraps(func) def inner(*args, **kwargs): print('вызов', func.__name__) return func(*args, **kwargs) return inner
Важно
Собственный декоратор всегда начинается с @functools.wraps(func) над внутренней функцией.
Изложение. У декорирования есть побочный эффект: имя area ссылается уже не на исходную функцию, а на inner со всеми её атрибутами. Имя, документация и сигнатура берутся от обёртки, help показывает справку об inner, а всё, что опирается на имена функций, — сбор документации, фреймворки, ищущие обработчики по имени, журналы — получает неверные сведения.
Декоратор functools.wraps, применённый к inner, копирует в обёртку метаданные оригинала — __name__, __qualname__, __doc__, __module__, __annotations__, с 3.12 и __type_params__, — дополняет __dict__ обёртки атрибутами оригинала и сохраняет ссылку на исходную функцию в атрибуте __wrapped__. По этой ссылке inspect.signature показывает сигнатуру оригинала, а при необходимости исходная функция вызывается в обход декоратора. Ещё одно следствие касается сериализации: без wraps декорированную функцию нельзя передать модулю pickle, поскольку обёртка называется trace.<locals>.inner, а с wraps можно, что важно для передачи функций в процессы multiprocessing.
Демонстрация:
декоратор trace
01
Декоратор trace без wraps
02
Декорированная функция area
03
Вызов: печать и результат
04
Имя функции после декорирования
05
Декоратор с functools.wraps
06
Имя после wraps и ссылка __wrapped__
Что наблюдается
Без wraps у функции area имя inner; с wraps имя сохранено, а __wrapped__ ведёт к исходной функции.
root@lab — запись со стенда
Демонстрация. Декоратор trace без wraps печатает имя функции и аргументы перед вызовом. area(2.0) печатает «вызов area (2.0,) {}» и возвращает 12.56636, но area.__name__ равно 'inner': имя принадлежит обёртке. После переопределения trace с @functools.wraps(func) декорированная функция volume сохраняет имя 'volume', а volume.__wrapped__ ссылается на исходную функцию.
Декораторы
с аргументами
def trace(handle): def decorator(func): @functools.wraps(func) def inner(*args, **kwargs): print(func.__name__, file=handle) return func(*args, **kwargs) return inner return decorator @trace(sys.stderr) def identity(x): return x
# @trace(sys.stderr) означает: decorator = trace(sys.stderr) identity = decorator(identity) # @trace и @trace(handle=sys.stderr): def trace(func=None, *, handle=sys.stdout): if func is None: return lambda f: trace(f, handle=handle) if not callable(func): raise TypeError('нужно trace(handle=...)') ...
Важно
Звёздочка не защищает от записи @trace(sys.stderr): файл попадает в параметр func, поэтому декоратор проверяет callable(func).
Изложение. Декоратор с настройками — например, trace с указанием, куда выводить, — требует третьего уровня вложенности. Запись @trace(sys.stderr) означает два действия: сначала вызывается trace(sys.stderr), и только возвращённый результат применяется к функции как декоратор. Значит, trace возвращает декоратор, а тот — обёртку.
Два вида декораторов несовместимы по способу применения: первый записывается как @trace, второй только как @trace(...). Чтобы работали оба варианта, декоратор анализирует первый аргумент: если функция получена, декоратор применён без скобок и сразу возвращает обёртку; если нет, вызов был со скобками, и возвращается декоратор, применяемый следующим шагом. Настройки при этом объявляются только именованными и передаются по имени: @trace(handle=sys.stderr). Звёздочка не защищает от записи @trace(sys.stderr): файловый объект всё равно попадает в параметр func, и ошибка возникает уже при декорировании, причём с малопонятным сообщением. Надёжная защита — проверка callable(func) с явным TypeError.
Цепочки
декораторов
@deco1 @deco2 def foo(): ... # применение снизу вверх: foo = deco1(deco2(foo))
Пояснение
@lru_cache располагают выше проверок, чтобы попадание в кеш не тратило время на проверку; @timethis — ниже, чтобы измерять саму функцию.
Изложение. На одной функции может быть несколько декораторов, и их порядок важен. Применяются они снизу вверх: ближайший к def — первым, и запись эквивалентна foo = deco1(deco2(foo)). Сами выражения после @ вычисляются сверху вниз, что заметно у декораторов с аргументами, а полученные декораторы применяются снизу вверх. При вызове управление проходит в обратную сторону: сначала обёртка внешнего deco1, затем внутреннего deco2, затем сама функция, а результат возвращается через deco2 и deco1.
Отсюда практические правила: @functools.lru_cache располагают выше проверок аргументов, чтобы попадание в кеш не расходовало время на проверку, а @timethis — ниже остальных, чтобы он измерял саму функцию, а не работу других обёрток.
Демонстрация:
порядок декораторов
01
Декоратор tag с аргументом
02
Два tag на одной функции
03
Вызов: порядок входа и выхода
Что наблюдается
Обёртка внешнего декоратора получает управление первой и возвращает его последней: обёртки вложены, как deco1(deco2(foo)).
root@lab — запись со стенда
Демонстрация. Декоратор с аргументом tag(name) печатает «вход» и «выход» со своим именем вокруг вызова функции. Функция f, помеченная @tag('внешний') и @tag('внутренний'), при вызове печатает: вход внешний, вход внутренний, тело, выход внутренний, выход внешний.
Практические
декораторы
Декоратор Задача Устройство
@timethis(n_iter=100) лучшее время из n запусков цикл с perf_counter, минимум
@once однократное выполнение флаг и результат в атрибутах обёртки
@memoized кеш результатов словарь в замыкании, ключ — аргументы
@deprecated предупреждение об устаревании warnings.warn(stacklevel=2)
@pre, @post проверка условий (контракты) assert до и после вызова
Для физика
@timethis берёт минимум из нескольких запусков: среднее искажается случайными помехами системы, а минимум показывает, на что способен код в их отсутствие.
Изложение. В главе книги разобраны пять практических декораторов. @timethis выполняет функцию n_iter раз и печатает лучшее время: среднее искажается любой случайной помехой наподобие переключения задач операционной системой, а минимум показывает, на что код способен без помех; по той же причине timeit.repeat возвращает список измерений, из которого берут min. @once выполняет функцию один раз и затем возвращает сохранённый результат — для чтения конфигурации или установки соединения; состояние он хранит в атрибутах обёртки. @memoized запоминает результат для каждого набора аргументов в словаре, ключом которого служит кортеж позиционных и отсортированных именованных аргументов, разделённых особым объектом-маркером: без маркера вызовы f(1, ('a', 2)) и f(1, a=2) получили бы один ключ. @deprecated предупреждает об устаревании функции, @pre и @post проверяют условия на входе и выходе.
Две последние темы рассматриваются на следующих слайдах, а мемоизация — в шестой части, где готовый functools.lru_cache заменяет самописный @memoized.
@deprecated
и предупреждения
def deprecated(func): @functools.wraps(func) def inner(*args, **kwargs): warnings.warn(f'{func.__name__} устарела', DeprecationWarning, stacklevel=2) return func(*args, **kwargs) return inner
01
При каждом вызове
warnings.warn внутри inner, а не в теле декоратора
02
stacklevel=2
строка вызывающего кода в сообщении
03
Видимость
по умолчанию — только для кода __main__
04
В тестах
-W error::DeprecationWarning: предупреждение как ошибка
Пояснение
С Python 3.13 то же делает готовый декоратор warnings.deprecated (PEP 702).
Изложение. Функцию, которую планируется удалить, сначала помечают устаревшей: она работает, но при каждом вызове выдаёт предупреждение, что даёт пользователям время переписать код; при фильтре по умолчанию оно выводится один раз для каждой строки, из которой функция вызвана. Предупреждение выдаётся внутри inner, то есть при каждом вызове; вынесенное в тело декоратора, оно сработало бы один раз при импорте модуля. Аргумент stacklevel=2 переводит указатель со строки внутри декоратора на строку, где функция вызвана, и относит предупреждение к модулю вызывающего кода. Без него место вызова по предупреждению не найти, а при фильтре по умолчанию предупреждение не выводится вовсе: DeprecationWarning показывается только для кода __main__.
DeprecationWarning по умолчанию скрыт везде, кроме кода модуля __main__; ключ -W default показывает все предупреждения, а -W error::DeprecationWarning превращает их в исключения, что удобно в тестах. С Python 3.13 стандартная библиотека содержит готовый декоратор warnings.deprecated (PEP 702), который вдобавок сообщает об устаревании анализаторам типов.
Демонстрация:
устаревшая функция
01
Декоратор deprecated в модуле legacy.py
02
Вызов из сценария: предупреждение со строкой вызова
03
Предупреждение как ошибка: -W error
Что наблюдается
stacklevel=2 указывает строку run.py:3, где вызвана функция; с -W error то же предупреждение прерывает выполнение.
root@lab — запись со стенда
Демонстрация. sed выводит декоратор deprecated из модуля legacy.py. Сценарий run.py импортирует помеченную функцию old_area и вызывает её: интерпретатор печатает «run.py:3: DeprecationWarning: old_area устарела» со строкой вызова, затем результат 3.14159. С ключом -W error::DeprecationWarning то же предупреждение становится исключением, и выполнение прерывается с трассировкой.
Контракты
через декораторы
def pre(cond, message): def decorator(func): @functools.wraps(func) def inner(*args, **kwargs): assert cond(*args, **kwargs), message return func(*args, **kwargs) return inner return decorator @pre(lambda x: 0 < x < 1, 'аргумент должен лежать в (0, 1)') def log_fraction(x): return math.log(x)
Важно
Ключ -O удаляет assert из байт-кода: внешние данные проверяются явным raise ValueError.
Для физика
Предусловие на физический смысл аргумента — доля, положительная масса, температура выше абсолютного нуля — обнаруживает ошибку на входе, а не в середине расчёта.
Изложение. Контрактное программирование описывает в самой функции условия, требуемые от входа, и обещания, даваемые на выходе. Декоратор @pre принимает проверяющую функцию и сообщение и перед вызовом проверяет аргументы; парный @post проверяет результат после вызова. Проверка видна рядом с объявлением, а тело функции свободно от проверок.
assert полностью исключается из байт-кода при запуске интерпретатора с ключом -O. Для контрактов внутри собственной программы это подходит: при отладке проверка выполняется, а в рабочем режиме не выполняется, и остаётся лишь вызов обёртки; чтобы убрать и его, декоратор возвращает функцию без обёртки: return inner if __debug__ else func. Данные, пришедшие извне — из файла, от пользователя, из сети, — через assert не проверяют: там нужен явный raise ValueError, который не исчезнет.
Для физиков: предусловие на физический смысл аргумента — доля в (0, 1), положительная масса, температура выше абсолютного нуля — останавливает расчёт на входе функции с понятным сообщением, а не порождает NaN, обнаруживаемый только в итоговом графике.
Демонстрация:
контракт и -O
02
Обычный запуск: AssertionError на 2.0
03
Запуск с -O без проверки
Что наблюдается
С ключом -O assert удаляется, и функция возвращает логарифм аргумента вне допустимого диапазона без сообщения об ошибке.
root@lab — запись со стенда
Демонстрация. tail показывает функцию log_fraction с предусловием 0 < x < 1 и два вызова. Обычный запуск печатает −0.693… для 0.5 и завершается AssertionError: аргумент должен лежать в (0, 1) для 2.0. Запуск с python3 -O удаляет assert, и второй вызов без сообщения об ошибке возвращает 0.693…: проверка удалена.
Декораторы
стандартной библиотеки
Декоратор Назначение
@functools.lru_cache, @cache мемоизация — шестая часть
@property, @classmethod, @staticmethod методы классов — следующая лекция
@dataclasses.dataclass класс данных — следующая лекция
@contextlib.contextmanager менеджер контекста из генератора
@atexit.register вызов при завершении интерпретатора
@warnings.deprecated('…') (3.13) пометка устаревших функций
@typing.overload варианты сигнатуры для анализаторов
Пояснение
Декоратор класса получает класс и возвращает класс: @dataclass дописывает методы __init__, __repr__ и __eq__ по аннотациям полей.
Изложение. Большая часть декораторов, встречающихся на практике, уже есть в стандартной библиотеке. functools.lru_cache и cache рассматриваются в следующей части. property, classmethod и staticmethod меняют поведение методов классов, dataclasses.dataclass порождает методы класса данных — это темы следующей лекции. contextlib.contextmanager превращает генератор в менеджер контекста для инструкции with. atexit.register назначает функцию, вызываемую при завершении интерпретатора. warnings.deprecated (3.13) с обязательным сообщением помечает устаревшие функции, typing.overload описывает для анализаторов типов несколько вариантов сигнатуры.
Декоратор можно применить и к классу: он получает класс и возвращает класс. @dataclass так и устроен — по аннотациям полей он дописывает в класс методы __init__, __repr__ и __eq__.
Рекурсия
и стек вызовов
def depth(n): return 0 if n == 0 else 1 + depth(n - 1) depth(900) # 900 depth(5000) # RecursionError def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2)
01
Кадр стека на вызов
аргументы и локальные переменные каждого вызова
02
Предел глубины
sys.getrecursionlimit() = 1000, затем RecursionError
03
Хвостовая рекурсия
без оптимизации, в отличие от функциональных языков
04
Решение
глубокую рекурсию — в цикл, повторные подзадачи — в кеш
Изложение. Функция может вызывать сама себя. Каждый вызов создаёт кадр стека со своими аргументами и локальными переменными, и кадры накапливаются, пока рекурсия не дойдёт до базового случая. Интерпретатор ограничивает глубину: sys.getrecursionlimit() по умолчанию возвращает 1000, и более глубокая рекурсия завершается RecursionError. Предел можно поднять sys.setrecursionlimit, но это откладывает, а не решает проблему; с 3.12 предел относится только к коду на Python. Хвостовую рекурсию Python не оптимизирует — это сознательное решение, сохраняющее полную трассировку ошибок.
Поэтому глубокая рекурсия переписывается циклом, при необходимости со своим стеком в списке или деке. Другая проблема — повторные подзадачи: наивная рекурсия для чисел Фибоначчи вычисляет одни и те же значения экспоненциально много раз. Её решает мемоизация на следующем слайде.
Для физиков: рекурсивный поиск связного кластера на решётке (перколяция, кластерный алгоритм Вольфа для модели Изинга) или обход графа в глубину идёт на глубину, равную размеру кластера, и на решётке 100×100 легко превышает 1000; итеративный обход со стеком из списка ограничения не имеет. Рекурсивный обход в глубину разобран и в главе книги «Графы».
Демонстрация:
предел рекурсии
01
Предел глубины рекурсии
03
Глубина 5000: RecursionError
Что наблюдается
Предел — 1000 кадров; цикл той же логики проходит миллион шагов без ограничения.
root@lab — запись со стенда
Демонстрация. sys.getrecursionlimit() возвращает 1000. Рекурсивная функция depth(n) возвращает n для n = 900, а при n = 5000 завершается RecursionError: maximum recursion depth exceeded; трассировка сворачивает повторяющиеся строки в «Previous line repeated 996 more times». Та же логика, записанная циклом while, считает до миллиона без ограничения.
Мемоизация:
lru_cache и cache
from functools import lru_cache @lru_cache(maxsize=None) def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) fib(32) # 2178309 fib.cache_info()
Для физика
Кеш окупается, когда функция многократно вызывается с одними аргументами: табличные функции, коэффициенты, дорогие интегралы. Массивы NumPy нехешируемы и ключом кеша быть не могут.
Средство Назначение
@lru_cache(maxsize=128) кеш с вытеснением давно не использованных записей
@cache (3.9) то же без ограничения размера
cache_info() попадания, промахи, размер
cache_clear() очистка кеша
typed=True 1 и 1.0 — разные ключи
Важно
Кеш с maxsize=None в долго работающей программе растёт неограниченно.
Изложение. functools.lru_cache — готовая замена самописному @memoized. Кеш ограничен по размеру maxsize, и при переполнении из него вытесняется запись, к которой дольше всего не обращались (least recently used). maxsize=None снимает ограничение; с Python 3.9 то же записывается короче — @functools.cache. Метод cache_info() показывает число попаданий и промахов: если попаданий почти нет, кеш только расходует память. cache_clear() очищает кеш, typed=True хранит аргументы разных типов, например 1 и 1.0, раздельно. Ограничение то же, что у самописного варианта: аргументы должны быть хешируемыми.
Рекурсивное вычисление чисел Фибоначчи без кеша вычисляет одни и те же значения экспоненциально много раз; с кешем каждое значение вычисляется однажды, и сложность становится линейной. Мемоизация при этом не снимает предел глубины: fib(2000) с пустым кешем завершается RecursionError, поэтому таблицу значений заполняют по возрастанию n или считают циклом. В отличие от @memoized, lru_cache не сортирует именованные аргументы: f(a=1, b=2) и f(b=2, a=1) занимают две записи.
Для физиков: кеш окупается для функций, многократно вызываемых с одними аргументами, — табличных функций, коэффициентов разложений, дорогих интегралов, зависящих от нескольких параметров. Массивы NumPy нехешируемы: функцию от массива так не кешируют, а в долго работающем процессе кеш без ограничения растёт вместе с числом различных аргументов.
Демонстрация:
мемоизация fib
01
Рекурсивный fib без кеша: время fib(32)
04
fib(400): однократное вычисление каждого значения
Что наблюдается
Без кеша fib(32) вычисляется за 0,15 с, с кешем — около 70 мкс: 33 промаха и 30 попаданий вместо семи миллионов вызовов.
root@lab — запись со стенда
Демонстрация. Рекурсивный fib без кеша вычисляет fib(32) = 2178309 примерно за 0,15 с, совершив около семи миллионов вызовов. После переопределения с @functools.lru_cache(maxsize=None) то же значение вычисляется примерно за 70 мкс; cache_info() показывает 33 промаха — по одному на каждое n от 0 до 32 — и 30 попаданий. fib(400) требует ещё 368 новых значений: остаток от деления на 10⁹ печатается сразу, а статистика кеша показывает 401 промах и 399 попаданий.
Частичное
применение
from functools import partial int2 = partial(int, base=2) int2('1010') # 10 def planck(nu, T): ... quad(partial(planck, T=5800.0), 1e14, 1e15)
partial lambda
закрепление при создании позднее связывание
сериализация pickle нет сериализации
атрибуты func, args, keywords только текст лямбды
Для физика
multiprocessing.Pool.map передаёт функцию в процессы через pickle: partial подходит, лямбда — нет; исходная функция — из импортируемого модуля.
Изложение. functools.partial закрепляет часть аргументов функции и возвращает новый вызываемый объект, которому закреплённые аргументы передавать уже не нужно: partial(int, base=2) переводит двоичные строки в числа. Это замена лямбде там, где готовую функцию нужно донастроить перед передачей дальше.
У partial три преимущества перед лямбдой. Значения аргументов закрепляются в момент создания, поэтому ловушки позднего связывания нет. Объект partial сериализуется модулем pickle, если сериализуемы исходная функция и закреплённые аргументы, а лямбда — нет. Закреплённые именованные аргументы можно переопределить при вызове: int2('10', base=10) даёт 10. Атрибуты func, args и keywords показывают, что именно закреплено, что удобно при отладке.
Для физиков: интегратор quad ожидает функцию одной переменной, а функция Планка зависит от частоты и температуры; partial(planck, T=5800.0) закрепляет температуру. При распараллеливании через multiprocessing.Pool.map функция передаётся в процессы-исполнители через pickle, и partial работает там, где лямбда вызывает ошибку сериализации. Исходная функция при этом определяется в импортируемом модуле: на macOS и Windows процессы запускаются методом spawn, с Python 3.14 и на Linux — методом forkserver, и функция из ячейки блокнота исполнителям недоступна.
Обобщённые функции:
singledispatch
from functools import singledispatch @singledispatch def describe(x): return 'объект' @describe.register def _(x: int): return 'целое' @describe.register def _(x: list): return f'список из {len(x)}'
Пояснение
Реализация выбирается по типу первого аргумента с учётом наследования: describe(True) даёт 'целое', поскольку bool — подкласс int.
Для физика
Функция обработки принимает число, список или массив и выбирает реализацию по типу входа без цепочки isinstance.
Изложение. Когда функция должна вести себя по-разному для разных типов, обычно применяется цепочка isinstance, которую приходится править при появлении каждого нового типа. С functools.singledispatch базовая функция объявляется один раз, а реализации для конкретных типов регистрируются отдельно, в том числе из другого модуля. С Python 3.7 тип указывается аннотацией первого параметра регистрируемой функции; зарегистрированные функции называют _, поскольку вызываться всегда будет базовая.
Выбор реализации выполняется по типу первого аргумента (отсюда single в названии) с учётом наследования: для True выбирается реализация для int, поскольку bool — подкласс int. Для методов классов предусмотрен singledispatchmethod (3.8).
Другие средства
functools
Средство Назначение
reduce(f, xs) свёртка последовательности; для произведения — math.prod (3.8)
total_ordering все сравнения класса по __eq__ и __lt__
cached_property (3.8) атрибут, вычисляемый при первом обращении
wraps, update_wrapper метаданные обёртки
partialmethod, singledispatchmethod варианты для методов классов
cmp_to_key перенос старых функций сравнения
Пояснение
reduce в Python применяется редко: sum, min, max, math.prod и явный цикл читаются лучше.
Изложение. reduce сворачивает последовательность в одно значение: применяет функцию к первым двум элементам, затем к результату и следующему элементу и так до конца. В функциональных языках это базовая конструкция, а в Python её применяют редко: явный цикл читается лучше, а частые свёртки уже есть в виде sum, min, max, any, all и math.prod.
Остальные средства модуля относятся к классам, обёрткам и сортировке. total_ordering достраивает все операции сравнения класса по __eq__ и одному из __lt__, __le__, __gt__, __ge__; cached_property вычисляет атрибут при первом обращении и запоминает значение; partialmethod и singledispatchmethod — варианты partial и singledispatch для методов; cmp_to_key переводит функцию сравнения старого стиля в функцию-ключ. Классы рассматриваются в следующей лекции.
Демонстрация:
functools
01
partial(int, base=2): двоичный разбор
02
Атрибуты func и keywords
03
singledispatch: реализации для int и list
04
Выбор по типу, включая bool
Что наблюдается
partial хранит закреплённые аргументы; singledispatch выбирает реализацию по типу с учётом наследования.
root@lab — запись со стенда
Демонстрация. int2 = partial(int, base=2) переводит '1010' и '1111' в 10 и 15; атрибуты int2.func и int2.keywords показывают закреплённые функцию и аргументы. Базовая describe возвращает 'объект', реализации для int и list регистрируются по аннотации первого параметра. describe(42), describe([1, 2]), describe('abc') и describe(True) дают 'целое', 'список из 2', 'объект' и снова 'целое': bool наследует int. reduce с умножением по range(1, 6) возвращает 120.
Итоги
лекции
01
Функция — объект: её передают, хранят и возвращают; аннотации документируют типы, но не проверяются.
02
Порядок параметров: только позиционные, /, обычные, *args, только именованные, **kwargs.
03
Значение по умолчанию вычисляется один раз; изменяемые значения заменяются на None.
04
Имена ищутся по LEGB; присваивание делает имя локальным, global и nonlocal это меняют.
05
Замыкание хранит переменные объемлющей функции; функция из цикла видит последнее значение, поэтому его фиксируют параметром по умолчанию, partial или фабрикой.
06
Декоратор — функция, принимающая функцию; wraps сохраняет метаданные, functools даёт готовые решения.
Изложение. Функция в Python — объект первого класса, и почти всё содержание лекции следует из этого: функции передают интеграторам и сортировке, возвращают из фабрик, хранят в замыканиях и оборачивают декораторами. Параметры объявляются в фиксированном порядке, настройки выносятся в только именованные, а изменяемые значения по умолчанию заменяются на None. Имена ищутся по правилу LEGB, присваивание делает имя локальным, а global и nonlocal позволяют менять внешние переменные явно. Замыкание сохраняет переменные объемлющей функции, а функции, созданные в цикле, требуют фиксировать значение параметром по умолчанию, partial или фабрикой. Декоратор — функция, принимающая функцию; собственный декоратор начинается с functools.wraps, а мемоизация, частичное применение и выбор по типу уже есть в functools.
Следующая лекция посвящена классам.
Упражнения для самопроверки
01
Функция unique(iterable, seen=set()) при повторном вызове на тех же данных возвращает пустой список: объяснить причину и исправить
02
Объяснить результат [f() for f in [lambda: i for i in range(3)]] и исправить тремя способами
03
Написать @timethis(n_iter=...) с functools.wraps и проверить help() и inspect.signature обёрнутой функции
04
Сравнить самописный @memoized и functools.lru_cache на рекурсивном fib: время и cache_info()
05
Написать фабрику make_gaussian(mu, sigma) и проинтегрировать результат через scipy.integrate.quad
Главы книги: «Функции», «Декораторы и functools» · продолжается задание «Решение задач на Python и анализ сложности»
Изложение. Отдельного задания к лекции нет: продолжается задание прошлой лекции «Решение задач на Python и анализ сложности». Упражнения на слайде служат самопроверке и опираются на главы книги «Функции» и «Декораторы и functools». Первое — ловушка изменяемого значения по умолчанию: правильная версия принимает None и создаёт множество внутри функции. Второе — позднее связывание; три способа исправления — параметр по умолчанию, functools.partial и фабрика функций. Третье проверяет, что декоратор с wraps сохраняет имя, документацию и сигнатуру. Четвёртое сравнивает самописную мемоизацию с готовой. Пятое связывает замыкания с численным интегрированием: для нормированной гауссианы интеграл по всей оси равен единице.
Источники
6
PEP 318, 570, 3102, 3104, 448, 484 — декораторы, параметры, nonlocal, распаковка, типы
peps.python.org
7
Ramalho L. Fluent Python. – 2nd ed. – O'Reilly, 2022
Изложение. Основой лекции служат главы книги курса «Функции» и «Декораторы и functools»; порядок изложения и часть примеров следуют лекциям курса «Python» Computer Science Center. Для самостоятельного изучения рекомендуются раздел учебника Python о функциях, документация модуля functools, перечисленные PEP и книга Л. Рамальо «Fluent Python», где функциям и декораторам посвящены отдельные главы.
Спасибо за внимание
Вопросы
phys-dev.github.io/soft-dev-book
Изложение. Надпись на последнем слайде — символ @, которым декоратор записывается перед функцией. Лекция завершается ответами на вопросы.