Python · Синтаксис · Продвинутый уровень

Декораторы

10 задач

Функции, оборачивающие другие функции для изменения или расширения их поведения. Охватывает синтаксис `@`, декораторы с аргументами, стекирование и `functools.wraps`.

Зачем декораторы, замыкания, анатомия и functools.wraps

#
**Зачем существуют декораторы** Представьте что нужно логировать каждый вызов функции или измерять сколько она выполняется. Без декораторов пришлось бы копировать этот код в каждую функцию. Декораторы позволяют написать логику один раз и прикрепить её где угодно: ```python # Без декораторов — нужно повторять везде: def compute(n): start = time.perf_counter() result = sum(range(n)) print(f'took {time.perf_counter() - start:.4f}s') return result # С декоратором — логика таймирования в одном месте: @timer def compute(n): return sum(range(n)) ``` Это называется *сквозная задача* — поведение которое относится ко многим функциям но не является частью работы ни одной из них. Декораторы — ответ Python на это. **Что на самом деле делает `@decorator`** Синтаксис `@` — чистое сокращение. Эти два варианта идентичны: ```python @timer def greet(name): return f'Hello, {name}!' # То же что: def greet(name): return f'Hello, {name}!' greet = timer(greet) # greet теперь wrapper который вернул timer ``` **Почему замыкания делают это возможным** Функция `wrapper` внутри декоратора *замыкается* над параметром `func` — она сохраняет ссылку на оригинальную функцию даже после того как `my_decorator` вернулся. Это фундаментальный механизм: ```python def my_decorator(func): # func это 'greet' при декорировании def wrapper(*args, **kwargs): print('before') # wrapper замыкается над func result = func(*args, **kwargs) # всё ещё имеет доступ к func print('after') return result return wrapper # возвращаем замыкание, не результат ``` `*args, **kwargs` в сигнатуре wrapper означает что декоратор работает с *любой* функцией независимо от количества параметров. **Всегда используйте `@functools.wraps`** Без него wrapper заменяет идентичность оригинальной функции. Отладка становится сложнее, а фреймворки проверяющие `__name__` или `__doc__` (FastAPI, pytest) ведут себя неожиданно: ```python import functools def my_decorator(func): @functools.wraps(func) # копирует __name__, __doc__, __annotations__ def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper @my_decorator def greet(name: str) -> str: 'Say hello to name.' return f'Hello, {name}!' greet.__name__ # 'greet' greet.__doc__ # 'Say hello to name.' greet.__annotations__ # {'name': str, 'return': str} ```

Фабрики декораторов, стекирование, декораторы на основе класса

#
**Декоратор с аргументами — паттерн фабрики** Простой декоратор принимает функцию и возвращает функцию. Если нужно передать *параметры* самому декоратору — добавляют ещё один слой: фабрику принимающую параметры и возвращающую настоящий декоратор: ```python import functools def repeat(n): # слой 1: фабрика — принимает аргумент def decorator(func): # слой 2: настоящий декоратор @functools.wraps(func) def wrapper(*args, **kwargs): # слой 3: обёртка for _ in range(n): result = func(*args, **kwargs) return result return wrapper return decorator @repeat(3) # repeat(3) возвращает декоратор; тот декоратор оборачивает say def say(msg): print(msg) say('hello') # печатает 'hello' три раза ``` **Стекирование декораторов** Можно применить несколько декораторов к одной функции. Они применяются снизу-вверх при декорировании, но вызываются сверху-вниз: ```python @timer # применяется вторым → самый внешний слой @log_calls # применяется первым → самый внутренний слой def compute(n): return sum(range(n)) # Эквивалентно: compute = timer(log_calls(compute)) # Порядок вызова при выполнении: # timer's wrapper → log_calls' wrapper → compute ``` **Декоратор на основе класса** Любой объект с методом `__call__` может быть декоратором. Полезно когда декоратор должен хранить состояние между вызовами: ```python import functools class CountCalls: def __init__(self, func): functools.update_wrapper(self, func) self.func = func self.count = 0 def __call__(self, *args, **kwargs): self.count += 1 return self.func(*args, **kwargs) @CountCalls def say_hi(): print('hi') say_hi() say_hi() print(say_hi.count) # 2 — состояние живёт в экземпляре CountCalls ``` > **О декораторах методов класса:** Python также имеет встроенные `@property`, `@classmethod` и `@staticmethod` — декораторы меняющие как метод привязывается к своему классу. Это отдельная тема, детально рассматриваемая в следующем разделе.

Реальные паттерны, ошибки, когда не использовать, анонс декораторов методов

#
**Реальные паттерны декораторов** ```python import functools, time # 1. Таймер — измерение времени выполнения def timer(func): @functools.wraps(func) def wrapper(*args, **kwargs): start = time.perf_counter() result = func(*args, **kwargs) elapsed = time.perf_counter() - start print(f'{func.__name__} took {elapsed:.4f}s') return result return wrapper # 2. Повтор при ошибке def retry(n=3, exceptions=(Exception,)): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): for attempt in range(1, n + 1): try: return func(*args, **kwargs) except exceptions: if attempt == n: raise return wrapper return decorator # 3. Singleton def singleton(cls): instances = {} @functools.wraps(cls) def get_instance(*args, **kwargs): if cls not in instances: instances[cls] = cls(*args, **kwargs) return instances[cls] return get_instance @singleton class Config: def __init__(self): self.debug = False Config() is Config() # True ``` **Распространённые ошибки** **1. Забытый return результата:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): func(*args, **kwargs) # БАГ: результат молча теряется return wrapper def good_decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) # всегда возвращать return wrapper ``` **2. Вызов wrapper вместо возврата wrapper:** ```python def bad_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper() # БАГ: вызывает wrapper немедленно def good_decorator(func): def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper # правильно: возвращаем объект функции ``` **Когда НЕ использовать декоратор** Декораторы добавляют косвенный вызов и скрывают логику. Используйте декоратор когда: - Поведение применяется ко многим несвязанным функциям - Оно чисто аддитивное (логирование, таймирование, кэширование, валидация) - Не меняет сигнатуру или тип возврата Избегайте декораторов когда поведение специфично для одной функции или существенно усложняет чтение кода. **Анонс: встроенные декораторы методов** Python имеет встроенные `@property`, `@classmethod` и `@staticmethod`. Они декорируют методы класса а не обычные функции, и взаимодействуют с протоколом дескрипторов Python — отдельным механизмом от паттерна декораторов функций описанного в этом разделе. Они детально рассматриваются в следующем topic.
01

Декоратор timer

#

Напишите декоратор `timer`, который измеряет время выполнения декорированной функции и печатает результат в формате: `'greet took 0.0001s'`. Используйте `time.time()` для измерения.

import time

def timer(func):
    pass


@timer
def greet(name):
    return f'Привет, {name}!'

greet('Алиса')  # печатает: greet took 0.0001s (примерно)
Решение
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f'{func.__name__} took {elapsed:.4f}s')
        return result
    return wrapper


@timer
def greet(name):
    return f'Привет, {name}!'

greet('Алиса')
02

Декоратор log_calls

#

Напишите декоратор `log_calls`, который печатает имя функции, её позиционные аргументы и возвращаемое значение каждый раз при вызове. Формат: `'add called with (2, 3) -> 5'`.

def log_calls(func):
    pass


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

add(2, 3)   # печатает: add called with (2, 3) -> 5
add(10, 20) # печатает: add called with (10, 20) -> 30
Решение
import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


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

add(2, 3)
add(10, 20)
03

Декоратор memoize

#

Напишите декоратор `memoize`, который кэширует результаты функции. При повторном вызове с теми же аргументами — вернуть кэшированное значение без повторного вызова функции. Хранить результаты в словаре с ключом `args`.

def memoize(func):
    pass


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

print(fib(10))  # 55
print(fib(30))  # 832040 (быстро благодаря кэшу)
Решение
import functools

def memoize(func):
    cache = {}
    @functools.wraps(func)
    def wrapper(*args):
        if args not in cache:
            cache[args] = func(*args)
        return cache[args]
    return wrapper


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

print(fib(10))  # 55
print(fib(30))  # 832040
04

Декоратор count_calls

#

Напишите декоратор `count_calls`, который отслеживает количество вызовов декорированной функции. Храните счётчик как атрибут `.count` на wrapper, чтобы код мог его читать.

def count_calls(func):
    pass


@count_calls
def say_hello():
    print('Привет!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
Решение
import functools

def count_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        wrapper.count += 1
        return func(*args, **kwargs)
    wrapper.count = 0
    return wrapper


@count_calls
def say_hello():
    print('Привет!')

say_hello()
say_hello()
say_hello()
print(say_hello.count)  # 3
05

Декоратор validate_positive

#

Напишите декоратор `validate_positive`, который проверяет каждый позиционный аргумент перед вызовом функции. Если любой аргумент не является положительным числом (≤ 0), выбросьте `ValueError` с сообщением `'All arguments must be positive'`.

def validate_positive(func):
    pass


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))   # 12
print(area(-1, 5))  # ValueError: All arguments must be positive
Решение
import functools

def validate_positive(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        if any(a <= 0 for a in args):
            raise ValueError('All arguments must be positive')
        return func(*args, **kwargs)
    return wrapper


@validate_positive
def area(width, height):
    return width * height

print(area(3, 4))  # 12
try:
    area(-1, 5)
except ValueError as e:
    print(e)  # All arguments must be positive
06

Декоратор once

#

Напишите декоратор `once`, который гарантирует, что декорированная функция выполнится только один раз. При первом вызове — выполните функцию и сохраните результат. При всех последующих вызовах — возвращайте сохранённый результат без повторного выполнения.

def once(func):
    pass


@once
def load_config():
    print('Загрузка конфигурации...')
    return {'debug': True}

c1 = load_config()  # печатает: Загрузка конфигурации...
c2 = load_config()  # ничего не печатает
c3 = load_config()  # ничего не печатает
print(c1 is c2)     # True
Решение
import functools

def once(func):
    sentinel = object()
    result = sentinel
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        nonlocal result
        if result is sentinel:
            result = func(*args, **kwargs)
        return result
    return wrapper


@once
def load_config():
    print('Загрузка конфигурации...')
    return {'debug': True}

c1 = load_config()
c2 = load_config()
print(c1 is c2)  # True
07

Фабрика декораторов retry

#

Напишите фабрику декораторов `retry(n)`, которая повторяет выполнение декорированной функции до `n` раз при возникновении любого исключения. Если все попытки провалились — перебросьте последнее исключение. Печатайте `'Attempt X failed'` после каждой неудачной попытки.

def retry(n):
    pass


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())  # Attempt 1 failed, Attempt 2 failed, затем: 'success'
Решение
import functools

def retry(n):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, n + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == n:
                        raise
                    print(f'Attempt {attempt} failed')
        return wrapper
    return decorator


attempts = [0]

@retry(3)
def flaky():
    attempts[0] += 1
    if attempts[0] < 3:
        raise RuntimeError('not ready')
    return 'success'

print(flaky())
08

@property: класс Temperature

#

Создайте класс `Temperature`, который хранит температуру в Цельсиях внутренне. Используйте `@property` для свойств `celsius` (геттер и сеттер) и `fahrenheit` (геттер и сеттер, конвертирующий из Фаренгейта в Цельсии). Формула: `F = C * 9/5 + 32`.

class Temperature:
    def __init__(self, celsius=0):
        pass

    # Добавьте свойство celsius (геттер + сеттер)
    # Добавьте свойство fahrenheit (геттер + сеттер)


t = Temperature(100)
print(t.celsius)      # 100
print(t.fahrenheit)   # 212.0
t.fahrenheit = 32
print(t.celsius)      # 0.0
Решение
class Temperature:
    def __init__(self, celsius=0):
        self._celsius = celsius

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

    @celsius.setter
    def celsius(self, value):
        self._celsius = value

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

    @fahrenheit.setter
    def fahrenheit(self, value):
        self._celsius = (value - 32) * 5 / 9


t = Temperature(100)
print(t.celsius)     # 100
print(t.fahrenheit)  # 212.0
t.fahrenheit = 32
print(t.celsius)     # 0.0
09

Фабрика декораторов requires_role

#

Напишите фабрику декораторов `requires_role(role)`, которая защищает функцию. Декорированная функция всегда получает словарь `user` первым аргументом. Если `user['role']` не равен `role`, выбросьте `PermissionError` с сообщением `'Access denied'`. Иначе — вызовите функцию нормально.

def requires_role(role):
    pass


@requires_role('admin')
def delete_record(user, record_id):
    return f"Запись {record_id} удалена пользователем {user['name']}"

admin = {'name': 'Алиса', 'role': 'admin'}
guest = {'name': 'Боб', 'role': 'guest'}

print(delete_record(admin, 42))  # Запись 42 удалена пользователем Алиса
delete_record(guest, 42)         # PermissionError: Access denied
Решение
import functools

def requires_role(role):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(user, *args, **kwargs):
            if user.get('role') != role:
                raise PermissionError('Access denied')
            return func(user, *args, **kwargs)
        return wrapper
    return decorator


@requires_role('admin')
def delete_record(user, record_id):
    return f"Запись {record_id} удалена пользователем {user['name']}"

admin = {'name': 'Алиса', 'role': 'admin'}
guest = {'name': 'Боб', 'role': 'guest'}

print(delete_record(admin, 42))
try:
    delete_record(guest, 42)
except PermissionError as e:
    print(e)  # Access denied
10

Стекирование декораторов

#

Примените декораторы `timer` и `log_calls` к функции `compute(n)`, которая возвращает сумму квадратов от 1 до n. Примените `@timer` снаружи и `@log_calls` внутри. Убедитесь, что вывод содержит и строку лога, и строку таймера.

import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


# Примените оба декоратора к compute:
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
Решение
import time
import functools

def timer(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        print(f'{func.__name__} took {time.time() - start:.4f}s')
        return result
    return wrapper

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        result = func(*args, **kwargs)
        print(f'{func.__name__} called with {args} -> {result}')
        return result
    return wrapper


@timer
@log_calls
def compute(n):
    return sum(i * i for i in range(1, n + 1))

compute(5)
# log_calls печатает: compute called with (5,) -> 55
# timer печатает: compute took 0.0000s