Декораторы в Python — что это и как применять на практике

Что такое декоратор

Декоратор — это функция, которая принимает другую функцию и возвращает новую, расширенную версию. Звучит абстрактно, но на деле это способ «обернуть» функцию дополнительной логикой — замерить время выполнения, добавить логирование, кэшировать результат — не меняя исходный код.

В Python функции — объекты первого класса: их можно передавать как аргументы, возвращать из других функций и сохранять в переменные. Именно на этом строится механизм декораторов.

Как декоратор работает изнутри

Разберём шаг за шагом. Допустим, мы хотим замерить время выполнения функции:

import time

def timer(func):
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        elapsed = time.time() - start
        print(f"{func.__name__} выполнилась за {elapsed:.3f} с")
        return result
    return wrapper

Что здесь происходит:

  1. timer принимает функцию func.
  2. Внутри создаётся функция-обёртка wrapper, которая вызывает func, но добавляет логику до и после.
  3. timer возвращает wrapper — новую функцию с той же сигнатурой.

Теперь применяем:

def load_data(path):
    # долгая загрузка данных
    import pandas as pd
    return pd.read_csv(path)

load_data = timer(load_data)  # «оборачиваем» вручную
df = load_data("events.csv")
# load_data выполнилась за 2.341 с

Строка load_data = timer(load_data) — и есть декорирование. Вместо неё Python предлагает синтаксический сахар.

Синтаксис @

Запись @decorator перед определением функции — эквивалент присваивания func = decorator(func):

@timer
def load_data(path):
    import pandas as pd
    return pd.read_csv(path)

# То же самое, что load_data = timer(load_data)

Короче, чище, нагляднее. Можно применить несколько декораторов — они выполняются снизу вверх:

@decorator_a
@decorator_b
def func():
    pass
# Эквивалент: func = decorator_a(decorator_b(func))

functools.wraps — сохраняем метаданные

Без functools.wraps обёрнутая функция теряет своё имя и docstring:

@timer
def load_data(path):
    """Загружает данные из CSV."""
    ...

print(load_data.__name__)  # 'wrapper' — не то, что хотели
print(load_data.__doc__)   # None

Решение — functools.wraps:

import functools

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

@timer
def load_data(path):
    """Загружает данные из CSV."""
    ...

print(load_data.__name__)  # 'load_data' ✓
print(load_data.__doc__)   # 'Загружает данные из CSV.' ✓

Правило: всегда используйте @functools.wraps(func) в обёртке. На собеседовании часто спрашивают, зачем это нужно.

Практические примеры

Логирование вызовов

import functools
import logging

def log_call(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        logging.info(f"Вызов {func.__name__}, args={args}, kwargs={kwargs}")
        result = func(*args, **kwargs)
        logging.info(f"{func.__name__} вернула {result}")
        return result
    return wrapper

@log_call
def calc_retention(cohort_size, returned):
    return returned / cohort_size if cohort_size else 0

Retry — повторные попытки

import functools
import time

def retry(max_attempts=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(1, max_attempts + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    if attempt == max_attempts:
                        raise
                    print(f"{func.__name__}: попытка {attempt} не удалась, повтор через {delay}с")
                    time.sleep(delay)
        return wrapper
    return decorator

@retry(max_attempts=3, delay=2)
def fetch_api_data(url):
    import requests
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return response.json()

Обратите внимание: retry — не сам декоратор, а фабрика декораторов. Она принимает параметры и возвращает настоящий декоратор. Это частый паттерн.

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

import functools

@functools.lru_cache(maxsize=128)
def expensive_calculation(n):
    """Считает что-то долго, результат кэшируется."""
    return sum(i ** 2 for i in range(n))

expensive_calculation(10000)  # считает
expensive_calculation(10000)  # берёт из кэша

lru_cache — встроенный декоратор из functools. Кэширует результаты вызовов по аргументам. Полезен для чистых функций (без побочных эффектов) с дорогими вычислениями.

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

@property — свойства

Позволяет обращаться к методу как к атрибуту:

class Experiment:
    def __init__(self, control, treatment):
        self.control = control
        self.treatment = treatment

    @property
    def lift(self):
        ctrl_mean = sum(self.control) / len(self.control)
        treat_mean = sum(self.treatment) / len(self.treatment)
        return (treat_mean - ctrl_mean) / ctrl_mean

exp = Experiment([100, 110, 95], [120, 115, 130])
print(exp.lift)  # 0.213... — без скобок, как атрибут

@staticmethod и @classmethod

class DataValidator:
    VALID_TYPES = {"int", "float", "str", "bool"}

    @staticmethod
    def is_positive(value):
        """Не нужен доступ к экземпляру или классу."""
        return value > 0

    @classmethod
    def from_config(cls, config_dict):
        """Альтернативный конструктор — доступ к классу через cls."""
        return cls(**config_dict)

@staticmethod — обычная функция внутри класса, не получает self и cls. @classmethod — получает класс первым аргументом, используется для альтернативных конструкторов.

Подробнее про lambda-функции — ещё один способ создания компактных функций.

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

Декорировать можно не только функции, но и классы. Самый частый пример — @dataclass:

from dataclasses import dataclass

@dataclass
class QueryResult:
    query: str
    rows: int
    execution_time: float

Декоратор @dataclass принимает класс и возвращает модифицированную версию с автоматически созданными __init__, __repr__, __eq__.

Вопросы с собеседований

Что такое декоратор в Python? — Функция, которая принимает функцию и возвращает новую функцию с расширенным поведением. Синтаксис @decorator — сахар для func = decorator(func). Используется для кросс-функциональной логики: логирование, кэширование, замер времени, контроль доступа.

Зачем нужен functools.wraps? — Без wraps обёрнутая функция теряет __name__, __doc__ и другие атрибуты — у неё будет имя обёртки (wrapper). @functools.wraps(func) копирует метаданные оригинала в обёртку. Это важно для отладки, документации и интроспекции.

Как написать декоратор с параметрами? — Нужна фабрика декораторов — функция, которая принимает параметры и возвращает сам декоратор. Три уровня вложенности: фабрика → декоратор → обёртка. Пример: @retry(max_attempts=3)retry принимает параметр и возвращает декоратор.

Чем @staticmethod отличается от @classmethod?@staticmethod не получает ни self, ни cls — это обычная функция, привязанная к пространству имён класса. @classmethod получает класс (cls) первым аргументом — полезен для альтернативных конструкторов и работы с наследованием.

FAQ

Декораторы замедляют код?

Минимально. Каждый вызов декорированной функции — это вызов обёртки плюс вызов оригинала. Накладные расходы — микросекунды. Для аналитических задач (обработка данных, SQL-запросы) это ничтожно мало. Не оптимизируйте то, что не является узким местом.

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

Когда одна и та же обвязка повторяется у многих функций: замер времени, логирование, повторные попытки при ошибках API, валидация входных данных. Если логика уникальна для одной функции — декоратор не нужен, просто напишите код внутри неё. Больше практик — в гайде по Python для аналитика.

Можно ли применить несколько декораторов к одной функции?

Да. Декораторы применяются снизу вверх: ближайший к def оборачивает первым, верхний — последним. Порядок важен. Например, @timer поверх @retry замерит общее время включая повторы, а в обратном порядке — время каждой попытки отдельно.


Потренируйте вопросы по Python на реальных задачах — откройте тренажёр. 1500+ вопросов, которые спрашивают на собеседованиях аналитика. Бесплатно.