Перейти к основному содержимому

Декораторы

Декоратор оборачивает функцию, добавляя ей поведение снаружи, без изменения её кода
Это один из самых частых паттернов в реальном Python - логирование, тайминг, кэширование (тот же functools.lru_cache из прошлой статьи - тоже декоратор), retry, а библиотеки вроде click строят на декораторах всю свою публичную обёртку

Презентация​

??

Теория​

Декоратор - это HOF​

Декоратор - обычная функция высшего порядка: принимает функцию и возвращает новую функцию с дополнительным поведением
Подробнее про функции как объекты первого класса и замыкания - в статье про функциональное программирование, здесь это база, без которой декоратор не собрать

Базовый декоратор​

import functools
from collections.abc import Callable
from typing import Any


def log_calls(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
print(f"вызов {func.__name__}(args={args}, kwargs={kwargs})")
result = func(*args, **kwargs)
print(f"{func.__name__} вернула {result!r}")
return result

return wrapper


@log_calls
def greet(name: str) -> str:
return f"привет, {name}"


greet("Алиса")
# вызов greet(args=('Алиса',), kwargs={})
# greet вернула 'привет, Алиса'

Точно типизировать декоратор (сохранить сигнатуру func один-в-один) можно через ParamSpec - это уже за рамками курса
Callable[..., Any] - прагматичный вариант: Any в конфиге ruff/mypy курса разрешён отдельно (правило ANN401 выключено) именно для таких случаев

@log_calls над greet - просто более короткая запись для greet = log_calls(greet)
*args, **kwargs в wrapper нужны, чтобы декоратор работал с функцией любой сигнатуры, а не только с одним конкретным набором аргументов

functools.wraps​

Без @functools.wraps(func) у обёрнутой функции подменяются __name__, __doc__ и другие метаданные - greet.__name__ станет "wrapper" вместо "greet", что ломает интроспекцию, автодокументацию и любой код, который читает имя функции

print(greet.__name__) # 'greet' - только благодаря functools.wraps

Декоратор с аргументами​

Если декоратору самому нужны параметры (@deprecated("...")), добавляется ещё один уровень вложенности - функция, которая принимает эти параметры и возвращает уже обычный декоратор:

import functools
import warnings
from collections.abc import Callable
from typing import Any


def deprecated(reason: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
warnings.warn(
f"{func.__name__} устарела: {reason}",
DeprecationWarning,
stacklevel=2,
)
return func(*args, **kwargs)

return wrapper

return decorator


@deprecated("используйте greet() вместо этого")
def old_greet(name: str) -> str:
return f"привет, {name}"


old_greet("Боря")
# DeprecationWarning: old_greet устарела: используйте greet() вместо этого
# вернёт 'привет, Боря'

stacklevel=2 здесь не случаен: без него предупреждение указывало бы на строку внутри wrapper - на деталь реализации декоратора, а не на место в коде, откуда old_greet реально вызвали
stacklevel=2 поднимает точку отчёта на один кадр вверх - к настоящему вызывающему коду
(Отдельно: если old_greet вызвать не из __main__, а из импортированного модуля, DeprecationWarning по умолчанию не покажется - Python показывает такие предупреждения только для кода, запущенного напрямую, если явно не включить фильтр через -W или warnings.simplefilter)

Здесь три уровня функций: deprecated(reason) принимает параметры самого декоратора, decorator(func) принимает оборачиваемую функцию, wrapper(*args, **kwargs) принимает аргументы конкретного вызова

Несколько декораторов на одной функции​

Декораторы применяются снизу вверх - ближайший к функции оборачивает её первым:

@log_calls
@deprecated("используйте greet() вместо этого")
def noisy_old_greet(name: str) -> str:
...

Здесь noisy_old_greet сначала оборачивается в deprecated(...), а получившийся результат - ещё раз в log_calls, поэтому лог вызова печатается раньше предупреждения об устаревании
Поменять декораторы местами - и предупреждение об устаревании выведется первым: deprecated окажется внешним и сработает раньше log_calls

click - декораторы в реальной библиотеке​

click - библиотека для CLI, построенная целиком на декораторах: @click.command() превращает обычную функцию в команду, @click.argument/@click.option регистрируют её параметры - вместо того, чтобы вручную разбирать sys.argv

uv add click
import click


@click.command()
@click.argument("a", type=int)
@click.argument("b", type=int)
def add(a: int, b: int) -> None:
click.echo(a + b)


if __name__ == "__main__":
add()

python add.py 2 3 выведет 5
Работает это ровно так же, как log_calls/deprecated выше - click.command/click.argument тоже просто функции, которые принимают функцию и возвращают новую, только с готовой инфраструктурой парсинга аргументов вместо ручного sys.argv

Примеры​

Продолжаем сквозную модель курсов и студентов из недели 3 - но не через самописный декоратор (это как раз то, что нужно сделать самостоятельно в автогрейдер-задаче), а через click, оборачивая уже готовый Registrar в маленький CLI

import click

lecture = Lecture(capacity=30)
seminar = Seminar(capacity=15)

alice = Student(name="Алиса", student_id="S1")
bob = Student(name="Боря", student_id="S2")

STUDENTS = {"S1": alice, "S2": bob}
LESSONS = {"L1": lecture, "L2": seminar}
registrar = Registrar()


@click.command()
@click.argument("student_id")
@click.argument("lesson_id")
def enroll(student_id: str, lesson_id: str) -> None:
"""Записать студента на занятие через CLI."""
ok = registrar.enroll(student=STUDENTS[student_id], lesson=LESSONS[lesson_id])
click.echo("записан" if ok else "уже был записан")


if __name__ == "__main__":
enroll()

python cli.py S1 L1 выведет записан
Если запустить ту же команду ещё раз, снова выведется записан - registrar создаётся заново при каждом запуске скрипта, состояние не переживает завершение процесса
Настоящее сохранение между запусками потребовало бы базы данных - это уже тема недели 7

Свой декоратор поверх Registrar - отдельная часть автогрейдер-задачи, которую нужно решить самостоятельно: техника та же, что в log_calls/deprecated выше (замыкание, wraps, при необходимости - уровень с параметрами декоратора), но применяется к реальным методам Registrar, а не к greet/old_greet, и конкретный тип декоратора определяется условием самой задачи - копировать код примеров один в один не получится

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

1. Что означает @decorator над функцией на самом деле?

Это более короткая запись для func = decorator(func) - синтаксический сахар, никакой дополнительной магии

2. Зачем в wrapper нужны *args, **kwargs?

Чтобы декоратор можно было применить к функции с любой сигнатурой, а не только к той, чьи аргументы заранее известны

3. Что сломается, если не использовать functools.wraps в декораторе?

У обёрнутой функции подменятся __name__, __doc__ и другие метаданные - вместо имени исходной функции они будут указывать на wrapper

4. Зачем декоратору с аргументами нужен третий уровень вложенности функций?

Первый уровень принимает параметры самого декоратора (reason в deprecated(reason)), второй - оборачиваемую функцию, третий - аргументы конкретного вызова этой функции
Без внешнего уровня было бы некуда передать reason

5. В каком порядке применяются несколько декораторов подряд над одной функцией?

Снизу вверх - декоратор, стоящий ближе всего к def, оборачивает функцию первым

6. Чем @click.command() по сути является для функции?

Обычным декоратором - функцией, которая принимает функцию и возвращает новую, только с готовой инфраструктурой разбора аргументов командной строки вместо ручного sys.argv

7. Почему повторный запуск CLI-команды из примера не покажет "уже был записан"?

Каждый запуск python cli.py ... - новый процесс, registrar создаётся заново и ничего не помнит о предыдущих запусках
Чтобы состояние сохранялось между запусками, нужно внешнее хранилище (например, база данных)

8. Можно ли перенести декоратор из примеров этой статьи прямо в автогрейдер-задачу?

Нет - log_calls/deprecated в примерах написаны для нейтральных функций (greet, old_greet), а задача просит применить свой декоратор к реальным методам Registrar, причём конкретный тип декоратора там свой - подход тот же, код придётся написать самостоятельно

9. Зачем в deprecated используется stacklevel=2 внутри warnings.warn, а не значение по умолчанию?

Без stacklevel=2 предупреждение указывало бы на строку внутри wrapper - на деталь реализации декоратора, а не на место в коде, откуда функцию реально вызвали
stacklevel=2 поднимает точку отчёта на один кадр вверх - к настоящему вызывающему коду

Итог / чек-лист​

  • Декоратор - HOF: принимает функцию, возвращает новую функцию с дополнительным поведением
  • @decorator над функцией - сокращение для func = decorator(func)
  • *args, **kwargs в wrapper нужны, чтобы декоратор работал с любой сигнатурой
  • functools.wraps сохраняет __name__/__doc__ исходной функции - без него интроспекция ломается
  • Декоратор с аргументами - на один уровень вложенности функций больше, чем обычный
  • Несколько декораторов подряд применяются снизу вверх
  • click строит свой API на декораторах (@click.command(), @click.argument, @click.option) - тот же механизм, что log_calls/deprecated, просто под задачу разбора аргументов командной строки
  • Состояние обычного CLI-скрипта живёт только внутри одного запуска процесса

Дополнительно​