$ sudo teach IT
Модуль 7 · Инструменты Python · Урок 7.6

Декораторы

Как добавить функции новое поведение снаружи, не трогая её код внутри, и почему это одна из самых изящных идей Python

Практика~35 минутНовичокФункции как объектыЗамыкания@decoratorfunctools.wrapslru_cache

Представьте: у вас есть десяток функций, и к каждой нужно добавить одно и то же — замерять время работы, печатать лог вызова, проверять права доступа. Лезть в тело каждой функции неудобно: код дублируется, а функция перестаёт заниматься одной задачей. Декораторы добавляют функции новое поведение снаружи, не трогая её код внутри. Но прежде нужно понять две вещи: что значит «функция как объект» и что такое замыкание — без них декоратор будет казаться магией.

Функции как объекты

Обычно вы думаете о функции как о команде: написали def, дали имя — и вызываете через скобки. Но в Python функция — это ещё и обычное значение, такое же, как число 5 или строка "привет". Её можно положить в переменную, передать в другую функцию как аргумент и даже вернуть как результат.

В документации это называется функции первого класса (first-class functions): значение, которое можно свободно передавать по программе, не вызывая. Именно на этом свойстве строятся декораторы: декоратор принимает функцию как значение и возвращает другую функцию как результат.

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

say_hello = greet
print(say_hello("Алиса"))
  • say_hello = greet — без скобок после greet. Скобки означали бы «вызови функцию сейчас». Без них — «сохрани саму функцию как объект». После этой строки say_hello и greet — два имени одной функции.
  • say_hello("Алиса") — скобки есть, значит функция вызывается. Через какое имя вызывать — неважно, результат одинаковый.
  • Вывод: Привет, Алиса!

Теперь передадим функцию как аргумент в другую функцию:

def run_twice(func, value):
    func(value)
    func(value)

def shout(text):
    print(text.upper())

run_twice(shout, "привет")
  • def run_twice(func, value): — параметр func ничем не отличается от value. В него можно передать функцию так же, как число или строку.
  • func(value) — внутри run_twice вызываем то, что лежит в func, передавая ей value.
  • run_twice(shout, "привет") — передаём саму функцию shout (без скобок!) и строку. Внутри func станет означать shout, а value — "привет".
  • Вывод: дважды ПРИВЕТ, потому что shout переводит текст в верхний регистр.

И последний вариант — функция может вернуть другую функцию:

def get_multiplier(n):
    def multiply(x):
        return x * n
    return multiply

double = get_multiplier(2)
print(double(5))
  • def multiply(x): внутри get_multiplier — функция, объявленная прямо внутри другой функции. def — обычная инструкция, её можно писать где угодно, в том числе внутри тела другой функции.
  • return multiply — без скобок: возвращаем саму функцию как объект, а не результат её вызова.
  • double = get_multiplier(2) — get_multiplier с n = 2 возвращает функцию multiply, она сохраняется в double.
  • double(5) — вызываем то, что лежит в double, передавая 5. Сработает x * n, где x = 5, n = 2.
  • Вывод: 10.

Загадка: get_multiplier уже завершилась, а double всё ещё помнит n = 2. Как? Ответ — в следующем разделе.

Замыкания: функция помнит своё окружение

Вложенная функция «видит» переменные внешней функции — так было с multiply, использовавшей n снаружи себя. Особенность в том, что эта связь не рвётся, даже когда внешняя функция уже закончила работу.

Официальный термин — замыкание (closure): вложенная функция вместе с переменными внешней, которые она использует. Python сохраняет эти переменные внутри самого объекта функции, поэтому они живут столько же, сколько она сама.

Пример счётчика:

def make_counter():
    count = 0

    def counter():
        nonlocal count
        count += 1
        return count

    return counter

my_counter = make_counter()
print(my_counter())
print(my_counter())
print(my_counter())
  • count = 0 — переменная в локальной области видимости make_counter.
  • def counter(): — вложенная функция, использует count, хотя сама её не объявляла.
  • nonlocal count — говорит Python: count внутри counter не новая переменная, а та, что во внешней функции. Без неё count += 1 вызвала бы ошибку.
  • return counter — возвращаем саму функцию, не вызывая её. Функция counter унесла с собой ссылку на count даже после завершения make_counter — это и есть замыкание.
  • Три вызова my_counter() печатают 1, 2, 3 — каждый раз count увеличивается на единицу.

nonlocal нужен только когда вложенная функция изменяет переменную из внешней (как count += 1). Для чтения без изменения он не нужен.

Первый декоратор без специального синтаксиса

Соединим функции как объекты и замыкания. Функция принимает другую функцию, создаёт третью — «обёртку» вокруг неё — и возвращает эту обёртку. Внутри обёртки можно выполнить что-то до вызова оригинала, вызвать сам оригинал и выполнить что-то после.

Официально такая функция называется декоратор (decorator): функция, принимающая функцию и возвращающая новую с добавленным поведением, не меняя код исходной.

def my_decorator(func):
    def wrapper():
        print("Перед вызовом")
        func()
        print("После вызова")
    return wrapper

def say_hi():
    print("Привет!")

say_hi = my_decorator(say_hi)
say_hi()
  • def my_decorator(func): — сам декоратор, принимает единственный аргумент func — функцию, которую нужно украсить.
  • def wrapper(): — новая функция внутри декоратора, обёртка: она «оборачивает» вызов оригинала своим кодом до и после.
  • func() внутри wrapper — вызывается оригинальная функция. Благодаря замыканию wrapper помнит, какая функция передана в func, даже вызываясь позже.
  • return wrapper — декоратор возвращает саму обёртку, не результат её вызова.
  • say_hi = my_decorator(say_hi) — получаем wrapper и переприсваиваем имя say_hi. Теперь оно указывает на wrapper, а тот умеет вызвать старую функцию через func.
  • say_hi() — на самом деле вызывается wrapper. Вывод: Перед вызовом, Привет!, После вызова.

Синтаксис @decorator — просто сокращение

Писать say_hi = my_decorator(say_hi) отдельной строкой после каждой функции неудобно. В Python для этого есть синтаксис декоратора — символ @ перед объявлением функции. Это не отдельная возможность языка, а сокращённая запись той же строки func = decorator(func).

def my_decorator(func):
    def wrapper():
        print("Перед вызовом")
        func()
        print("После вызова")
    return wrapper

@my_decorator
def say_hi():
    print("Привет!")

say_hi()
  • @my_decorator над def say_hi(): означает: как только функция объявлена, сразу передай её в my_decorator и замени имя на результат. Происходит один раз, при объявлении, не при каждом вызове.
  • Результат идентичен предыдущему примеру: Перед вызовом, Привет!, После вызова.

Никакой магии: @my_decorator над функцией — то же самое, что func = my_decorator(func) после неё, просто короче и не забудешь.

Декоратор для функции с аргументами и результатом

say_hi не принимала аргументов и ничего не возвращала — в жизни так почти не бывает. Декоратор должен украшать любую функцию. Знакомый *args и **kwargs собирает в обёртке любые аргументы, чтобы передать их дальше, в оригинал.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Вызов функции {func.__name__}")
        result = func(*args, **kwargs)
        print(f"Функция вернула: {result}")
        return result
    return wrapper

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

total = add(3, 5)
print(total)
  • def wrapper(*args, **kwargs): — обёртка принимает любые позиционные (в кортеж args) и именованные (в словарь kwargs) аргументы, поэтому подходит для любой функции.
  • func.__name__ — встроенный атрибут со строкой имени функции, нужен для вывода.
  • func(*args, **kwargs) вызывает оригинал, разворачивая собранное обратно в отдельные аргументы.
  • return result — без этой строки wrapper вернёт None, и декорированная функция всегда будет отдавать None вместо настоящего результата.
  • add(3, 5) запускает wrapper(3, 5): args станет (3, 5). Вывод: Вызов функции add, Функция вернула: 8, затем 8.

functools.wraps: сохраняем имя и docstring

У декорированной функции есть незаметный побочный эффект: имя и docstring подменяются на имя и docstring обёртки. Это мешает при отладке.

def my_decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

print(add.__name__)
print(add.__doc__)
  • """Складывает два числа.""" под def add — docstring, Python хранит её в атрибуте __doc__.
  • add.__name__ выведет wrapper, а не add — имя add теперь указывает на объект-обёртку с собственным именем.
  • add.__doc__ выведет None — у wrapper своего docstring нет.

Решение — functools.wraps из стандартной библиотеки: копирует __name__, __doc__ и другие метаданные оригинала на обёртку:

import functools

def my_decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

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

print(add.__name__)
print(add.__doc__)
  • @functools.wraps(func) — сам является декоратором, применяется к wrapper внутри вашего. Принимает func, чтобы знать, чьи метаданные копировать.
  • После этой строки wrapper.__name__ станет "add", а wrapper.__doc__ — докстрингу оригинала. Поведение декоратора не меняется, меняются только служебные атрибуты.
  • Вывод теперь: add, затем Складывает два числа.

Правило на будущее: в любом своём декораторе добавляйте @functools.wraps(func) над wrapper. Стоит одной строки, а спасает от долгих поисков, почему help(моя_функция) показывает не то.

Декоратор с собственными аргументами

Иногда декоратор хочется настроить: например, @repeat(3) — повторить вызов функции трижды. Такая конструкция называется фабрикой декораторов (decorator factory): функция, принимающая аргументы настройки и возвращающая готовый декоратор. Три уровня вложенности вместо двух: внешняя принимает настройки, средняя — саму функцию (это и есть декоратор), внутренняя выполняет работу при вызове.

import functools

def repeat(times):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            result = None
            for _ in range(times):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Привет!")

say_hello()
  • def repeat(times): — уровень 1, принимает настройку (число повторов) и готовит декоратор.
  • def decorator(func): — уровень 2, настоящий декоратор: принимает функцию, возвращает обёртку. Видит times через замыкание.
  • def wrapper(*args, **kwargs): — уровень 3: цикл вызывает func(*args, **kwargs) нужное число раз, перезаписывая result, в нём остаётся результат последнего вызова.
  • @repeat(3) над say_hello: сначала вызывается repeat(3), возвращает decorator с зашитым times = 3, тот применяется к say_hello. Вывод: три раза Привет!.

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

На функцию можно навесить сразу несколько декораторов, перечислив их построчно над def. Применяются они снизу вверх — тот, что ближе к def, срабатывает первым.

import functools

def bold(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return "**" + func(*args, **kwargs) + "**"
    return wrapper

def uppercase(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs).upper()
    return wrapper

@bold
@uppercase
def greet(name):
    return f"привет, {name}"

print(greet("Алиса"))
  • bold оборачивает результат звёздочками, uppercase переводит его в верхний регистр — два независимых декоратора.
  • @bold стоит выше, @uppercase — ближе к def greet: эквивалент greet = bold(uppercase(greet)).
  • При вызове первым срабатывает внешний bold, он вызывает uppercase, тот — настоящий greet: "привет, Алиса" → "ПРИВЕТ, АЛИСА" → "**ПРИВЕТ, АЛИСА**".

Применение декораторов к функции идёт снизу вверх (ближний к def — первый), а выполнение при вызове — сверху вниз (внешний срабатывает первым).

Практика: декоратор для замера времени

Декоратор timer узнаёт, сколько секунд работала функция, и печатает это:

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__}] выполнилась за {elapsed:.4f} сек")
        return result
    return wrapper

@timer
def slow_sum(n):
    total = 0
    for i in range(n):
        total += i
    return total

result = slow_sum(1_000_000)
print(f"Результат: {result}")
  • time.time() возвращает текущее время в секундах. Само число не важно — важна разница двух таких чисел, засечённых до и после вызова func.
  • elapsed = time.time() - start даёт длительность выполнения; {elapsed:.4f} форматирует её с четырьмя знаками после запятой.
  • result сохранён и возвращён, как в разделе 5, иначе slow_sum всегда возвращала бы None.

Практика: декоратор для логирования вызовов

Ещё один частый случай — печатать, с какими аргументами вызвали функцию и что она вернула, вместо ручного print в каждой функции:

import functools

def log_calls(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        args_repr = [repr(a) for a in args]
        kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()]
        signature = ", ".join(args_repr + kwargs_repr)
        print(f"Вызов: {func.__name__}({signature})")
        result = func(*args, **kwargs)
        print(f"Вернула: {result!r}")
        return result
    return wrapper

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

divide(10, 2)
  • [repr(a) for a in args] и [f"{k}={v!r}" for k, v in kwargs.items()] — генераторы списков собирают строковое представление каждого аргумента ({v!r} в f-строке — то же, что repr(v)).
  • ", ".join(...) склеивает оба списка через запятую в строку вида 10, 2. Вывод: Вызов: divide(10, 2), затем Вернула: 5.0.

Практика: functools.lru_cache

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

Классический пример — рекурсивный подсчёт чисел Фибоначчи, где без кэша одни значения пересчитываются миллионы раз:

import functools

@functools.lru_cache(maxsize=None)
def fib(n):
    if n <= 1:
        return n
    return fib(n - 1) + fib(n - 2)

print(fib(35))
  • @functools.lru_cache(maxsize=None) — готовая фабрика декораторов, как и ваш repeat. maxsize=None хранит результаты без ограничения; число, например 128, ограничило бы кэш 128 последними вызовами (LRU — Least Recently Used, давно неиспользуемый вытесняется первым).
  • При первом fib(5) функция считает результат и запоминает пару «аргумент → результат». Повторный вызов с теми же аргументами вернёт сохранённое значение, не выполняя тело заново — поэтому fib(35) с кэшем считается мгновенно, а без него — секунды.

Начиная с Python 3.9 есть более короткая запись: @functools.cache — то же самое, что @functools.lru_cache(maxsize=None), без ограничения размера кэша.

Частые ошибки

Забыли вернуть результат из wrapper

Если в wrapper написать func(*args, **kwargs) без return, декорированная функция всегда вернёт None: print(add(3, 5)) покажет None вместо 8.

Обёртка без *args, **kwargs

def wrapper(): без параметров подходит только для функций без аргументов. Декорировав так add(a, b), вызов add(3, 5) даст TypeError: wrapper() takes 0 positional arguments but 2 were given.

Вызов func вместо возврата func

return func() внутри самого декоратора (не внутри wrapper) вызовет функцию сразу при декорировании и вернёт её результат вместо новой функции. Последующий вызов упадёт с TypeError: 'int' object is not callable.

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

@repeat(3) требует три уровня (repeat → decorator → wrapper). С двумя, как в обычном декораторе, Python попытается использовать число 3 как саму функцию и упадёт с TypeError: 'int' object is not callable.

Что важно запомнить

  • Функции в Python — такие же объекты, как числа и строки: их можно сохранять в переменных, передавать и возвращать
  • Замыкание — вложенная функция, которая помнит переменные внешней функции даже после её завершения
  • Декоратор — функция, которая принимает функцию и возвращает новую функцию с добавленным поведением
  • @decorator над def — сокращённая запись func = decorator(func), применяется один раз при объявлении функции
  • Обёртка должна принимать *args, **kwargs и всегда возвращать result оригинального вызова
  • @functools.wraps(func) над обёрткой сохраняет её имя и docstring — ставьте в каждом своём декораторе
  • Декоратор с аргументами (@repeat(3)) — три уровня вложенности: фабрика, декоратор, обёртка
  • Несколько декораторов применяются снизу вверх, а выполняются при вызове — сверху вниз
  • functools.lru_cache — готовый декоратор кэширования результатов из стандартной библиотеки

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

7 вопросов

Декоратор timer

Напиши декоратор timer, который замеряет время выполнения функции и печатает его в формате: Функция '{name}' выполнилась за {elapsed:.4f} сек.

Используй functools.wraps для сохранения имени и docstring оригинальной функции.

Подсказка: используй time.time() до и после вызова функции.

Декоратор repeat

Premium