Продвинутые декораторы: параметризованные, class-based, classmethod

В прошлом уроке разобрали базовые декораторы - функция оборачивает функцию. В этом уроке - сложнее: декораторы с параметрами, реализованные через классы, и встроенные декораторы для методов классов. Эти приёмы лежат в основе фреймворков типа FastAPI, Click и SQLAlchemy.

Параметризованные декораторы

Что если декоратор должен принимать настройки? Например, retry с произвольным числом попыток:

@retry(times=3, delay=1)
def fetch():
    ...

retry(times=3, delay=1) это не сам декоратор, а функция, возвращающая декоратор. Поэтому нужен дополнительный уровень вложенности:

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:
                    if attempt == times - 1:
                        raise
                    time.sleep(delay)
        return wrapper
    return decorator

Структура - три уровня:

  1. retry(...) - принимает параметры, возвращает декоратор
  2. decorator(func) - сам декоратор, принимает функцию, возвращает wrapper
  3. wrapper(*args, **kwargs) - то, что заменит оригинальную функцию

Эквивалент без @:

decorator = retry(times=3, delay=1)
fetch = decorator(fetch)

Декоратор, который и с параметрами, и без

Иногда хочется поддерживать оба синтаксиса:

@logged          # без параметров
@logged()        # с пустыми параметрами
@logged(level="DEBUG")  # с параметрами
def f(): ...

Это решается через functools.wraps и проверку типа:

def logged(func=None, *, level="INFO"):
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f"[{level}] Calling {func.__name__}")
            return func(*args, **kwargs)
        return wrapper

    if func is None:
        return decorator   # вызвано с параметрами: @logged(level="DEBUG")
    return decorator(func) # без параметров: @logged

Используя trick: первый параметр default None, остальные - keyword-only.

Class-based декораторы

Декоратор может быть классом, реализующим __call__:

from functools import wraps

class Timer:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.calls = 0
        self.total_time = 0

    def __call__(self, *args, **kwargs):
        import time
        start = time.perf_counter()
        result = self.func(*args, **kwargs)
        elapsed = time.perf_counter() - start
        self.calls += 1
        self.total_time += elapsed
        return result

    def stats(self):
        avg = self.total_time / self.calls if self.calls else 0
        return f"calls={self.calls}, avg={avg:.4f}s"

@Timer
def compute(x):
    return x ** 2

compute(5)
compute(10)
print(compute.stats())   # calls=2, avg=...

Преимущества:

  • Декоратор может хранить состояние в атрибутах инстанса
  • Дополнительные методы доступны через декорированную функцию
  • Более структурно для сложных декораторов

Недостатки:

  • Сложнее с методами класса (нужна особая работа с self)
  • Более многословно для простых случаев

Class-based с параметрами

class Retry:
    def __init__(self, times=3, delay=1):
        self.times = times
        self.delay = delay

    def __call__(self, func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(self.times):
                try:
                    return func(*args, **kwargs)
                except Exception:
                    if attempt == self.times - 1:
                        raise
                    time.sleep(self.delay)
        return wrapper

@Retry(times=5, delay=2)
def fetch():
    ...

@property

Превращает метод в свойство, доступ как к атрибуту:

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return 3.14159 * self.radius ** 2

c = Circle(5)
print(c.area)   # 78.539... - вызов как к атрибуту, без скобок

С setter:

class Temperature:
    def __init__(self, celsius):
        self._celsius = celsius

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Ниже абсолютного нуля")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.fahrenheit)   # 77.0
t.celsius = 30        # вызывается setter с валидацией
t.celsius = -300      # ValueError

Это даёт encapsulation с удобным синтаксисом: пользователь обращается как к атрибуту, но за кадром есть логика. Полный разбор property, classmethod и staticmethod - в уроке про атрибуты и методы.

- Производное значение, которое надо вычислять (не хранить отдельно) - Валидация при записи - Lazy computation - первый доступ вычисляет, последующие возвращают cached значение (см. `@functools.cached_property`) - Контролируемый доступ к "приватному" атрибуту (с _underscore)

Не злоупотребляй: для простых атрибутов property избыточен. Используй когда есть реальная выгода.

@staticmethod и @classmethod

class Date:
    def __init__(self, year, month, day):
        self.year = year
        self.month = month
        self.day = day

    @classmethod
    def today(cls):
        import datetime
        now = datetime.datetime.now()
        return cls(now.year, now.month, now.day)

    @classmethod
    def from_string(cls, s):
        year, month, day = map(int, s.split("-"))
        return cls(year, month, day)

    @staticmethod
    def is_leap_year(year):
        return year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)

d1 = Date.today()
d2 = Date.from_string("2026-05-27")
print(Date.is_leap_year(2024))   # True
ДекораторПолучаетИспользование
@staticmethodНичего особенногоУтилитарная функция, логически в namespace класса
@classmethodКласс как clsAlternative constructors, factory methods
Обычный методИнстанс как selfСтандартный метод

@classmethod особенно хорош для factory methods - создание объекта альтернативным способом. И поддерживает inheritance:

class SpecialDate(Date):
    pass

d = SpecialDate.today()  # вернёт SpecialDate, а не Date
print(type(d))   # SpecialDate

cls в classmethod - это фактический класс из которого вызывают, не Date.

Декорирование классов целиком

def add_str(cls):
    def __str__(self):
        attrs = ", ".join(f"{k}={v}" for k, v in self.__dict__.items())
        return f"{cls.__name__}({attrs})"
    cls.__str__ = __str__
    return cls

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

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

Это паттерн используется в @dataclass (модуль 6), @attrs.define и подобных.

@dataclass - встроенный класс-декоратор

from dataclasses import dataclass

@dataclass
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
print(p)         # Point(x=1.0, y=2.0)
print(p == Point(1.0, 2.0))   # True

@dataclass автоматически генерирует __init__, __repr__, __eq__ на основе аннотаций. Подробно про него в уроке про dataclass и Enum.

Декораторы с side effects при импорте

ROUTES = {}

def route(path):
    def decorator(func):
        ROUTES[path] = func
        return func
    return decorator

@route("/users")
def get_users():
    return [...]

@route("/posts")
def get_posts():
    return [...]

Декоратор регистрирует функцию в глобальном словаре ROUTES. После импорта модуля все маршруты автоматически собраны. Так работают Flask, FastAPI и многие другие фреймворки.

Side effects при импорте - удобно, но усложняют отладку. Если порядок импортов изменится, регистрация может пойти не туда. И тестировать сложнее.

Альтернатива - явная регистрация: app.include_router(routes). Чуть многословнее, но предсказуемо.

Полезные декораторы из functools

ДекораторЧто делает
@functools.wraps(func)Сохраняет метаданные декорируемой функции
@functools.lru_cache(maxsize=128)Мемоизация с LRU
@functools.cache (3.9+)Простая мемоизация без вытеснения
@functools.cached_propertyСвойство, кеширующее результат на инстансе
@functools.total_orderingГенерирует все <, <=, >, >= из __eq__ и одного из них
@functools.singledispatchPolymorphism по типу первого аргумента

Распространённые ошибки

1. Параметризованный декоратор без скобок

@retry            # ОШИБКА - не вызвали retry()
def fetch(): ...

# должно быть
@retry(times=3)
def fetch(): ...

Декоратор с параметрами требует обязательного вызова.

2. Property без setter

class A:
    @property
    def x(self):
        return self._x

a = A()
a.x = 5   # AttributeError - property без setter read-only

Если хочется записывать, нужен @x.setter.

3. Mutable state в class-based декораторе

@Timer
def f(): ...

@Timer
def g(): ...

# f.calls и g.calls - НЕЗАВИСИМЫЕ, потому что каждый декоратор - свой инстанс

Это правильно. Если бы Timer хранил calls как class-attribute, было бы shared - тогда f и g делили бы счётчик.

Сравнение с Go и PHP

В Go нет декораторов как языковой конструкции, но похожее достигается через HOF:

func Logged(f func(int) int) func(int) int {
    return func(x int) int {
        log.Printf("calling with %d", x)
        return f(x)
    }
}

square = Logged(square)

В PHP до 8.0 декораторов не было, добавили Attributes (PHP 8) для метаданных, но они не модифицируют функцию автоматически - нужен runtime-reader.

Python декораторы исторически и идиоматически очень развиты. Многие фреймворки построены на них.

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

  1. Параметризованный декоратор для лимита вызовов:
from functools import wraps

def max_calls(limit):
    def decorator(func):
        calls = 0
        @wraps(func)
        def wrapper(*args, **kwargs):
            nonlocal calls
            if calls >= limit:
                raise RuntimeError(f"Превышен лимит {limit} вызовов")
            calls += 1
            return func(*args, **kwargs)
        return wrapper
    return decorator

@max_calls(3)
def greet(name):
    return f"Hello, {name}"

greet("A")  # OK
greet("B")  # OK
greet("C")  # OK
greet("D")  # RuntimeError
  1. Class-based декоратор с stats:
from functools import wraps

class CallCounter:
    def __init__(self, func):
        wraps(func)(self)
        self.func = func
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.func(*args, **kwargs)

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

add(1, 2)
add(3, 4)
print(add.count)   # 2
  1. Property с валидацией:
class Temperature:
    def __init__(self, celsius):
        self.celsius = celsius   # вызовет setter

    @property
    def celsius(self):
        return self._celsius

    @celsius.setter
    def celsius(self, value):
        if value < -273.15:
            raise ValueError("Ниже абсолютного нуля")
        self._celsius = value

    @property
    def fahrenheit(self):
        return self._celsius * 9 / 5 + 32

t = Temperature(25)
print(t.fahrenheit)   # 77.0
t.celsius = -300       # ValueError

Что дальше

Модуль 4 завершён. Освоили функции на всех уровнях: базовое определение, гибкие параметры, closures, лямбды, декораторы простые и продвинутые. В следующем модуле перейдём к коллекциям: списки, кортежи, словари, множества и инструменты collections.

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