Декораторы: @decorator, functools.wraps, типичные паттерны

Декоратор - функция, которая принимает другую функцию и возвращает изменённую версию. Под капотом это замыкание плюс *args/**kwargs. В Python это один из самых выразительных инструментов: им построены маршруты во Flask/FastAPI, property/staticmethod в классах, регистрация плагинов, кеширование, retry-логика и многое другое. В этом уроке - базовый синтаксис, типичные паттерны и грабли.

Зачем нужны декораторы

Представь что хочешь логировать вызовы нескольких функций:

def add(a, b):
    return a + b

def multiply(a, b):
    return a * b

def divide(a, b):
    return a / b

Можно вписать print в каждую - но это повторение, и логика логирования размазана по коду. Декоратор позволяет вынести её отдельно:

def logged(func):
    def wrapper(*args, **kwargs):
        print(f"Вызываю {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"Результат: {result}")
        return result
    return wrapper

@logged
def add(a, b):
    return a + b

add(2, 3)
# Вызываю add((2, 3), {})
# Результат: 5
# 5

@logged над def add равносильно add = logged(add). То есть add теперь это wrapper, который оборачивает оригинальный add.

Базовый шаблон

def my_decorator(func):
    def wrapper(*args, **kwargs):
        # код ДО вызова
        result = func(*args, **kwargs)
        # код ПОСЛЕ вызова
        return result
    return wrapper

@my_decorator
def some_function():
    ...

Структура:

  1. Декоратор принимает функцию
  2. Внутри определяет wrapper, который вызывает функцию
  3. Возвращает wrapper
  4. @my_decorator применяет это к декорируемой функции

Использование functools.wraps

Если оставить декоратор как выше, метаданные декорируемой функции теряются:

@logged
def add(a, b):
    """Складывает два числа."""
    return a + b

print(add.__name__)   # wrapper - не add!
print(add.__doc__)    # None - docstring потерян

Это плохо для отладки, IDE-подсказок и инспекции. Решение - functools.wraps:

from functools import wraps

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Вызываю {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logged
def add(a, b):
    """Складывает два числа."""
    return a + b

print(add.__name__)   # add
print(add.__doc__)    # Складывает два числа.

@wraps(func) переносит __name__, __doc__, __wrapped__ и другие метаданные с оригинальной функции на wrapper. Всегда используй wraps в своих декораторах.

Типичные паттерны

1. Логирование

@wraps(func)
def wrapper(*args, **kwargs):
    print(f"[{datetime.now()}] {func.__name__}({args}, {kwargs})")
    return func(*args, **kwargs)

2. Замер времени

import time
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

@timed
def heavy():
    sum(x ** 2 for x in range(1_000_000))

3. Кеширование (мемоизация)

def memoize(func):
    cache = {}

    @wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper

@memoize
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

В стандартной библиотеке уже есть готовый @functools.lru_cache и @functools.cache - используй их вместо собственного memoize в большинстве случаев.

4. Проверка прав доступа

def require_admin(func):
    @wraps(func)
    def wrapper(user, *args, **kwargs):
        if not user.is_admin:
            raise PermissionError("Только для администраторов")
        return func(user, *args, **kwargs)
    return wrapper

@require_admin
def delete_user(user, target_id):
    db.delete(target_id)

5. Retry с backoff

import time

def retry(times=3, delay=1):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(times):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == times - 1:
                        raise
                    print(f"Попытка {attempt + 1} упала: {e}")
                    time.sleep(delay * (2 ** attempt))
        return wrapper
    return decorator

@retry(times=3, delay=1)
def fetch_data():
    response = requests.get("http://api.example.com")
    response.raise_for_status()
    return response.json()

Это параметризованный декоратор - подробно про них в следующем уроке.

Несколько декораторов

Декораторы можно стэкать:

@logged
@timed
@memoize
def compute(x):
    return x ** 2

Это эквивалентно:

compute = logged(timed(memoize(compute)))

Применение снизу вверх: сначала memoize, потом timed, потом logged. Порядок важен - меняет поведение.

Пример: что произойдёт при compute(5):

  • logged.wrapper(5) - логирует вход
  • timed.wrapper(5) - засекает время
  • memoize.wrapper(5) - проверяет кеш
  • → если нет в кеше: compute(5) - реальный вызов
  • → результаты идут обратно по цепочке

При втором вызове compute(5):

  • logged.wrapper(5) - залогирует
  • timed.wrapper(5) - засечёт время
  • memoize.wrapper(5) - вернёт из кеша

Время будет крошечное (только overhead логирования и timing), реальный compute не выполнится.

Декораторы для классов

Декорировать можно и классы:

def add_repr(cls):
    def __repr__(self):
        attrs = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({attrs})"
    cls.__repr__ = __repr__
    return cls

@add_repr
class User:
    def __init__(self, name, age):
        self.name = name
        self.age = age

print(User("Alice", 30))   # User(name='Alice', age=30)

В стандартной библиотеке такое уже сделано через @dataclass - модуль 6 «ООП».

Подводные камни

1. Забыл @wraps

# Плохо
def logged(func):
    def wrapper(*args, **kwargs):
        print(f"call")
        return func(*args, **kwargs)
    return wrapper

# Метаданные теряются
@logged
def add(): pass

print(add.__name__)   # wrapper

Всегда используй @wraps(func).

2. Mutable state в decorator

# Плохо - count shared между всеми декорированными функциями
counter = 0

def counted(func):
    def wrapper(*args, **kwargs):
        global counter
        counter += 1
        return func(*args, **kwargs)
    return wrapper

Если хочется счётчик для каждой функции отдельно, нужно держать в closure:

def counted(func):
    count = 0
    @wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal count
        count += 1
        result = func(*args, **kwargs)
        return result
    wrapper.count = lambda: count  # доступ к счётчику
    return wrapper

3. Применение декоратора к методу

Декораторы работают и с методами, но wrapper должен принимать self:

def logged(func):
    @wraps(func)
    def wrapper(self, *args, **kwargs):
        print(f"Calling {func.__name__} on {self}")
        return func(self, *args, **kwargs)
    return wrapper

class User:
    @logged
    def greet(self):
        print(f"Hello from {self}")

Или используй *args, **kwargs - тогда self пройдёт через них без специальной обработки.

4. Декоратор не сохраняет сигнатуру

@logged
def add(a: int, b: int) -> int:
    return a + b

import inspect
print(inspect.signature(add))   # без @wraps: (*args, **kwargs)
                                # с @wraps:    (a: int, b: int) -> int

С @wraps сигнатура сохраняется в __wrapped__, и inspect.signature её находит. Без этого IDE не покажет правильные подсказки.

Когда не использовать декораторы

  • Когда нужно изменить поведение только в одном месте (просто впиши логику)
  • Когда декоратор делает что-то сильно неочевидное - читателю придётся искать
  • Когда стек декораторов больше 3-4 - сложно отследить порядок
  • Когда нужна явная конфигурация на call-site - параметр функции читаемее

Встроенные декораторы в Python

ДекораторЧто делает
@propertyПревращает метод в свойство (доступ как атрибут)
@staticmethodМетод без self - просто функция в namespace класса
@classmethodМетод принимает класс как первый аргумент вместо self
@functools.wrapsСохраняет метаданные декорируемой функции
@functools.lru_cacheМемоизация с LRU
@functools.cacheПростая мемоизация без вытеснения (3.9+)
@functools.total_orderingГенерирует все операторы сравнения из eq и lt
@dataclassГенерирует init, repr, eq из аннотированных полей
@contextlib.contextmanagerПревращает генератор в context manager

Мини-задание

  1. Декоратор логирования с wraps:
from functools import wraps

def logged(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        print(f"Calling {func.__name__}({args}, {kwargs})")
        result = func(*args, **kwargs)
        print(f"  -> {result}")
        return result
    return wrapper

@logged
def add(a, b):
    """Складывает два числа."""
    return a + b

add(3, 5)
print(add.__name__)   # add (благодаря @wraps)
print(add.__doc__)    # Складывает два числа.
  1. Замер времени для горячих функций:
import time
from functools import wraps

def timed(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.perf_counter()
        result = func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        print(f"{func.__name__}: {elapsed:.4f}s")
        return result
    return wrapper

@timed
def heavy():
    return sum(x ** 2 for x in range(10_000_000))

heavy()
  1. Стек декораторов:
@logged
@timed
def slow_calc(n):
    time.sleep(0.1)
    return n * 2

slow_calc(5)
# Calling slow_calc((5,), {})
# slow_calc: 0.1024s
#   -> 10

Что дальше

Освоили базовые декораторы. В следующем уроке - продвинутые декораторы: параметризованные, class-based, classmethod/staticmethod/property, и как декорировать класс целиком.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.