Декораторы: выражения сверху вниз, применение снизу вверх
Декоратор — обычный вызов, который случается один раз, когда выполняется def. Выражения при этом читаются сверху вниз, а применяются снизу вверх — из этой пары растёт всё остальное: и зачем нужен functools.wraps, и почему @classmethod под своим декоратором ломается молча.
Полное техническое изложение
TL;DR
@d над def f — это не объявление и не магия. Это f = d(f): функцию
передали декоратору, а результатом заменили имя. Присваивание случается один
раз, в тот момент, когда интерпретатор дошёл до def. При вызове работает уже
обёртка, которую декоратор вернул; сам декоратор больше не стоит ничего — он
отработал.
Отсюда главное следствие: когда декораторов несколько, выражения
вычисляются сверху вниз, а применяются снизу вверх. Из этой пары растут
почти все неожиданности: и порядок обёрток, и то, почему @classmethod под
своим декоратором ломается не при определении класса, а при первом вызове, и
почему functools.wraps — не вежливость, а условие работоспособности
отладчика, документации и pickle.
Дальше — числа, версии и границы. wraps делает обёртку похожей на
цель, а не равной ей: inspect.signature показывает сигнатуру цели только
потому, что идёт по __wrapped__, — сама обёртка по-прежнему принимает
*args, **kwargs. Слой стоит измеримо: 17,3 нс без декораторов против 53,5 с
одним слоем на 3.13.7, около 36 нс за слой. А встроенные декораторы зависят от
версии: объект staticmethod вызываем с 3.10, объект classmethod — нет, и
@classmethod над @property с 3.13 возвращает не значение, а связанный
метод.
- функция — такой же объект, как число или список: её можно передать в другую функцию и вернуть из неё;
- функция, объявленная внутри другой функции, помнит переменные, объявленные вокруг неё;
- вызов можно принять «как есть», не перечисляя параметры поимённо — через
*argsи**kwargs.
functools.wraps,__wrapped__,__qualname__,inspect.signature;- байткод, дескрипторы, устройство
staticmethodиclassmethod, флаги корутинности.
База: что делает знак @
Декоратор — это обычная функция, которая принимает функцию и возвращает то, что дальше будет стоять под её именем. Это определение целиком: ни особого режима исполнения, ни отдельного механизма в интерпретаторе за ним нет.
Знак @ — сокращение записи. Строчки
@d
def f(): ...значат ровно то же, что
def f(): ...
f = d(f)Функцию передали декоратору, и результатом заменили имя. Дальше под именем
f живёт не то, что написано в исходнике, а то, что вернул d. Обычно это
обёртка — новая функция, которая внутри зовёт исходную и делает вокруг вызова
что-то своё: считает время, проверяет права, пишет в журнал.
Из одной этой равносильности следуют три вещи, и они же — тот минимум, который спрашивают.
Первое: декоратор выполняется один раз, вместе с def. Присваивание
f = d(f) стоит рядом с определением, а не внутри тела функции, — значит,
случается оно тогда, когда интерпретатор дошёл до определения, а не тогда,
когда функцию позвали.
Второе: при вызове выполняется обёртка. Позвали f(...) — позвали то, что
вернул декоратор. Исходная функция выполнится, только если обёртка её позовёт.
Отсюда же и самая частая поломка — «декоратор ничего не делает»:
обёртку забыли вернуть, и под именем осталось None.
Третье: если декораторов несколько, они применяются снизу вверх. Нижний
получает исходную функцию, следующий — результат нижнего, и так до верхнего.
Это не соглашение об оформлении, а прямое следствие того, что каждая строка с
@ — ещё одно присваивание вокруг предыдущего результата.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше —
про то, что порядок вычисления выражений после @ противоположен порядку их
применения; про то, почему обёртка без functools.wraps ломает отладчик и
сериализацию; и про то, почему привычные @staticmethod и @classmethod
ведут себя в этой схеме не как функции.
Механизм 1: один вызов в момент определения
@d над def — это f = d(f) в момент определения. Ни версия, ни реализация этого не меняют.Справочник языка описывает декоратор в четырёх предложениях, и в них уже есть всё:
Decorator expressions are evaluated when the function is defined, in the
scope that contains the function definition. The result must be a callable,
which is invoked with the function object as the only argument. The returned
value is bound to the function name instead of the function object.
Выражения-декораторы вычисляются в момент определения функции, в области видимости, содержащей это определение. Результат должен быть вызываемым объектом; он вызывается с объектом функции в качестве единственного аргумента. Возвращённое значение связывается с именем функции вместо объекта функции.
Проверим, что when the function is defined
(в момент определения функции) — это буквально:
log = []
def d(f):
log.append("декоратор отработал")
return f
@d
def g(): pass
print(log) # ['декоратор отработал'] — ещё до единственного вызова g
g(); g()
print(log) # ['декоратор отработал'] — по-прежнему одинПрактическое следствие важнее формулировки: декоратор — это код, который
выполняется вместе с def. Для функции на верхнем уровне модуля это значит —
при импорте. Регистрация обработчика, чтение переменной окружения, открытие
соединения, записанные внутрь декоратора, произойдут в момент импорта, а не при
первом вызове. Модуль, который импортируется ради
одной функции, оплатит их целиком.
Механизм 2: сверху вниз, снизу вверх
Тот же справочник даёт эквивалентность:
@f1(arg)
@f2
def func(): pass
# примерно то же, что:
def func(): pass
func = f1(arg)(f2(func))Ниже те же шаги проиграны по одному: слева исходник, справа стек. Смотреть надо на то, в какой момент появляется сама функция, — оба декоратора к этому времени уже лежат на стеке.
Из записи f1(arg)(f2(func)) видно применение снизу вверх: внутренний f2
отрабатывает первым. Но в ней НЕ видно, что f1 и arg вычисляются раньше
f2. Это разные вещи, и разделяет их такой опыт:
events = []
class Named:
def __init__(self, tag): self.tag = tag
def __call__(self, f):
events.append(f"применён {self.tag}")
return f
def build(tag):
events.append(f"вычислено выражение {tag}")
return Named(tag)
@build("верхний")
@build("нижний")
def f(): pass
for e in events: print(e)вычислено выражение верхний
вычислено выражение нижний
применён нижний
применён верхний
Байткод показывает то же самое ещё нагляднее — здесь и дальше python3.13:
import dis
dis.dis(compile("@d1\n@d2(arg)\ndef f():\n pass\n", "<урок>", "exec")) 1 LOAD_NAME 0 (d1)
2 LOAD_NAME 1 (d2)
PUSH_NULL
LOAD_NAME 2 (arg)
CALL 1
3 LOAD_CONST 0 (<code object f>)
MAKE_FUNCTION
2 CALL 0
1 CALL 0
3 STORE_NAME 3 (f)
Служебные RESUME и возврат None опущены — они есть в выводе, но к порядку
декораторов отношения не имеют.
d1 загружается ПЕРВЫМ — раньше, чем d2 вообще вычислен. Затем создаётся
функция, и только потом идут два CALL в обратном порядке. Компилятор
буквально складывает декораторы в стек сверху вниз и снимает снизу вверх.
Почему именно так, объясняет PEP 318: порядок применения matches the usual
order for function-application. In mathematics, composition of functions
(g o f)(x) translates to g(f(x))
(совпадает с обычным порядком применения функций. В математике композиция функций (g ∘ f)(x) разворачивается в g(f(x))).
Механизм 3: имя не связывается по дороге
В конце эквивалентности из справочника стоит оговорка, которую обычно пропускают:
…except that the original function is not temporarily bound to the name func.
…с той разницей, что исходная функция не связывается временно с именем func.
То есть запись func = f1(arg)(f2(func)) неточна: в ней func на мгновение
существует неукрашенным. В настоящем декорировании — нет. В байткоде выше это
видно прямо: STORE_NAME f ровно один, в самом конце.
Проверить можно и без байткода:
seen = []
def probe(f):
seen.append("h" in globals())
return f
@probe
def h(): pass
print(seen) # [False] — имени h ещё нетПрактический смысл: рекурсивный вызов по имени внутри декорируемой функции попадёт в декорированную версию, а не в исходную, потому что к моменту первого вызова по имени связан уже результат декоратора. Это ровно то, чего обычно и хотят от кеширующего декоратора, и ровно то, что ломает наивные попытки «обойти обёртку» через собственное имя.
Механизм 4: декоратор с аргументами — это два вызова
@d и @d(...) — не два вида декораторов, а один механизм. После @ стоит
выражение; его результат вызывается с функцией. Если выражение само является
вызовом, вызовов становится два:
calls = []
def factory(n):
calls.append(("фабрика", n))
def deco(f):
calls.append(("декоратор", f.__name__))
return f
return deco
@factory(3)
def m(): pass
print(calls) # [('фабрика', 3), ('декоратор', 'm')]С Python 3.9 после @ допустимо любое выражение (PEP 614). До этого грамматика
требовала имя с точками — модуль.объект.атрибут — и необязательный вызов,
поэтому @buttons[0].clicked.connect — пример из самого PEP — приходилось
выносить во временную переменную.
Механизм 5: что делает functools.wraps и чего не делает
Без wraps обёртка честно сообщает о себе — то есть о себе, а не о цели:
import functools, inspect, pickle
def naive(f):
def wrapper(*a, **kw): return f(*a, **kw)
return wrapper
def target(x: int, y: str = "s") -> bool:
"Документация цели."
return True
n = naive(target)
print(n.__name__) # 'wrapper'
print(n.__doc__) # None
print(inspect.signature(n)) # (*a, **kw)С wraps — иначе:
def careful(f):
@functools.wraps(f)
def wrapper(*a, **kw): return f(*a, **kw)
return wrapper
c = careful(target)
print(c.__name__) # 'target'
print(inspect.signature(c)) # (x: int, y: str = 's') -> boolИ вот здесь начинается то, о чём почти не говорят:
print(inspect.signature(c, follow_wrapped=False)) # (*a, **kw) -> boolСигнатура цели видна не потому, что обёртка её приобрела, а потому, что
inspect по умолчанию идёт по цепочке __wrapped__, которую wraps
проставляет автоматически. Отключите переход — и обёртка снова окажется
(*a, **kw). Возвращаемый тип при этом остался: он скопирован как часть
__annotations__.
Это различие не педантизм. wraps не проверяет аргументы за вас: обёртка
по-прежнему принимает что угодно, и опечатка в имени именованного аргумента
дойдёт до цели, а не будет отвергнута на границе. Инструменты, читающие
__wrapped__ (inspect, help, отладчики, генераторы документации), увидят
правду; сам вызов — нет.
Разложить wraps полезно на три отдельные задачи — путают их, потому что
решает их одна строка:
| задача | что делает wraps | что остаётся как было |
|---|---|---|
| метаданные | копирует __name__, __doc__, __module__, __qualname__, __dict__, __annotations__ | ничего: это простое копирование |
цепочка __wrapped__ | проставляет ссылку на цель, по которой ходит inspect | цепочку нужно уметь снимать: inspect.unwrap |
| соглашение о вызове | ничего | обёртка принимает (*args, **kwargs), и проверка имён остаётся на цели |
Первые две строки — про то, что увидят люди и инструменты. Третья — про то, что произойдёт при вызове, и именно её отсутствие в списке и есть ответ на вопрос «почему опечатка прошла».
Что wraps чинит по-настоящему:
@naive
def pnaive(): return 1
@careful
def pcareful(): return 1
pickle.dumps(pnaive) # ошибка: Can't pickle local object ...wrapper
pickle.dumps(pcareful) # работаетТип исключения при этом зависит от версии, и это стоит знать, если ловите его
по типу: на 3.11, 3.12 и 3.13 это AttributeError, на 3.14 — PicklingError.
На 3.13 изменился и текст: Can't get local object вместо
Can't pickle local object.
pickle ищет функцию по паре __module__ + __qualname__. У необёрнутой
обёртки это naive.<locals>.wrapper — путь, по которому ничего не найти.
wraps копирует оба атрибута, и функция снова становится адресуемой. Тот же
механизм лежит за работой multiprocessing и любых очередей задач, которые
передают функцию по имени.
Глубже: снять слои — inspect.unwrap и подделка сигнатуры
__wrapped__, который wraps проставляет автоматически, — это цепочка, и по
ней ходят два разных обхода. Знать их различие приходится в тот день, когда
документация врёт о вызове.
Первый обход — inspect.unwrap. Он снимает все слои:
Get the object wrapped by func. It follows the chain of __wrapped__
attributes returning the last object in the chain. […] For example, signature
uses this to stop unwrapping if any object in the chain has a __signature__
attribute defined. ValueError is raised if a cycle is encountered.
Получить объект, обёрнутый func. Функция идёт по цепочке атрибутов __wrapped__ и возвращает последний объект цепочки. […] Например, signature использует это, чтобы остановить раскрутку, если у какого-нибудь объекта в цепочке определён атрибут __signature__. ValueError возбуждается при обнаружении цикла.
Проверено на трёх обёртках над одной функцией (bench/introspection/unwrap_and_signature.py,
вывод одинаков на 3.11–3.14; здесь и ниже он приведён сокращённо — только
относящиеся к делу строки):
1) цепочка __wrapped__ сверху вниз: ['c', 'b', 'a', 'ОРИГИНАЛ']
и все три зовутся 'target': ['target', 'target', 'target']
2) inspect.unwrap(three) is target: True
один __wrapped__ снимает только слой: его layer: b
3) unwrap(..., stop=layer=='a') остановился на слое: a
4) цикл в __wrapped__ -> ValueError: wrapper loop when unwrapping <function loop_a at 0x...>
Три обёртки, три одинаковых __name__ — то есть по имени слой не определить,
а по __wrapped__ определить можно, и unwrap доходит до дна за один вызов.
stop= останавливает его на нужном слое.
Второй обход — внутри inspect.signature, и он останавливается раньше, если у
слоя есть __signature__. Атрибут этот документация называет деталью
реализации:
If the passed object has a __signature__ attribute, we may use it to create the
signature. The exact semantics are an implementation detail and are subject to
unannounced changes. Consult the source code for current semantics.
Если у переданного объекта есть атрибут __signature__, мы можем использовать его для построения сигнатуры. Точная семантика — деталь реализации и может измениться без объявления. За текущей семантикой обращайтесь к исходному коду.
«Деталь реализации» здесь не значит «менялось»: формулировка и поведение совпадают на 3.11, 3.12, 3.13 и 3.14 построчно.
А значит это вот что. Обёртка, поставившая себе __signature__, показывает
инструментам ту сигнатуру, какую захотела, и follow_wrapped=False от этого
не спасает — атрибут стоит на самой обёртке и побеждает в обоих случаях:
6) обёртка с __signature__:
signature(lying) (count: int, *, label: str = '?') -> None
signature(lying, follow_wrapped=False) (count: int, *, label: str = '?') -> None
__wrapped__ по-прежнему указывает на target: True
но unwrap всё равно доходит до target: True
Последняя строка — то самое различие двух обходов: __signature__ тормозит
signature и не тормозит unwrap.
Чем это кончается на практике:
8) str(inspect.signature(lying)) -> "(count: int, *, label: str = '?') -> None"
bind(count=1, label='x') по ПОДДЕЛЬНОЙ сигнатуре прошёл
а реальный вызов lying(count=1, label='x') -> TypeError: target() got an unexpected keyword argument 'count'
Signature.bind — та самая проверка «подойдут ли аргументы», на которую
опираются валидаторы и маршрутизаторы, — сверяется с подделкой и пропускает
вызов, который упадёт. Здесь не соврал объект кода: у обёртки из примера выше
реальные параметры так и остались ('a', 'kw').
Правило отсюда стоит писать сразу с границей. Для функции, написанной на
Python, дело обстоит так: __wrapped__ и __signature__ — для людей и
документации, а __code__ показывает параметры её собственного тела. Для
обёртки, объявленной как (*args, **kwargs), это ровно то, что нужно: видно,
что она принимает что угодно, а signature в это время рассказывает про цель.
За пределами этого случая правило не работает — и не потому, что даёт неверный
ответ, а потому, что применить его не к чему. Прогон
bench/introspection/code_object_limits.py показывает границу:
len (встроенная) AttributeError
объект с __call__ AttributeError
partial(target, 1) AttributeError
dict.get (метод C-типа) AttributeError
У всех четырёх вызов есть, а __code__ нет вовсе: он бывает только у функции,
написанной на Python. И даже там, где он есть, он описывает тело, а не вызов:
у связанного метода в нём остаётся self, которого вызывающий не передаёт, а у
обёртки выше не видно, что она дописывает flag=True к вызову цели. Ни
__code__, ни signature про это не знают.
Поэтому «за правдой ходят к объекту кода» — правило с двумя оговорками, и обе
обязательны: оно про функцию на Python и про её собственное тело. Для
произвольного вызываемого объекта — встроенного, экземпляра с __call__,
partial, метода C-типа — такой модели нет вовсе, и вопрос «что он на самом
деле принимает» решается не интроспекцией, а документацией и вызовом.
Глубже: асинхронный декоратор и корутинность
Самая частая поломка при переносе декоратора на async def выглядит так:
обёртка синхронная, цель — корутинная функция, wraps на месте, и всё вроде бы
работает. Не работает вот что (bench/introspection/async_decorator.py):
1) iscoroutinefunction(job) : True
iscoroutinefunction(broken): False
2) job.__code__.co_flags & CO_COROUTINE : True
broken.__code__.co_flags & CO_COROUTINE: False
Причина в том, где живёт признак. Корутинность — это флаг объекта кода
(CO_COROUTINE, 0x0080), а functools.wraps копирует атрибуты функции. До
флага он не дотягивается по определению, и __wrapped__ тут не помогает:
iscoroutinefunction по цепочке не ходит.
7) iscoroutinefunction(broken) : False
iscoroutinefunction(inspect.unwrap(broken)): True
Ломается от этого не декоратор, а тот, кто по признаку выбирает путь:
3) dispatch(job, 21) -> 42
dispatch(broken, 21)-> coroutine <coroutine object job at 0x...>
вместо числа наружу уехал объект корутины: он не выполнен,
и при сборке мусора даст RuntimeWarning 'was never awaited'
То есть ошибка не падает в точке декорирования и не падает в точке вызова.
Она всплывает предупреждением сборщика мусора неизвестно где — ровно тот
класс, который разобран в уроке про async.
Починок две. Переносимая — сделать обёртку async def, и тогда флаг ставит
компилятор. С 3.12 появилась вторая, для случая, когда обёртка обязана
оставаться синхронной:
Decorator to mark a callable as a coroutine function if it would not otherwise
be detected by iscoroutinefunction. […] When possible, using an async def
function is preferred.
Декоратор, помечающий вызываемый объект как корутинную функцию, если иначе iscoroutinefunction её не распознаёт. […] Когда возможно, предпочтительна функция async def.
Она чинит ответ, не трогая флаг, и это видно:
3.12.3 и новее
5) inspect.markcoroutinefunction доступна: True
iscoroutinefunction(marked): True
а флаг кода по-прежнему НЕ корутинный: False
маркер — обычный атрибут: True
На 3.11 её нет вовсе, и переносимый способ там один — async def.
Если декоратор должен работать и над обычными функциями, и над корутинными, выбирать форму обёртки надо по цели, а не по вере:
def deco(f):
if inspect.iscoroutinefunction(f):
@functools.wraps(f)
async def wrapper(*a, **kw): return await f(*a, **kw)
else:
@functools.wraps(f)
def wrapper(*a, **kw): return f(*a, **kw)
return wrapperПроверено: признак сохраняется в обе стороны, и оба вызова работают.
Отдельно, если ловите это в чужом коде: с 3.14 asyncio.iscoroutinefunction
выдаёт DeprecationWarning и назначена к удалению в 3.16 — проверять надо
inspect.iscoroutinefunction.
Глубже: свой декоратор поверх staticmethod и classmethod
Два встроенных декоратора возвращают дескрипторы, а не функции — объекты,
которые лежат в классе и вмешиваются в обращение к атрибуту; что это такое и
почему из-за них ломается именно @classmethod, разобрано в уроке
«Дескрипторы». Поведение при этом
асимметрично, и асимметрия не выводится из общих соображений, а проверяется
запуском:
def trace(f):
@functools.wraps(f)
def w(*a, **kw): return f(*a, **kw)
return w
class A:
@trace
@staticmethod
def f(x): return x * 2
A.f(3) # 6 — работает
class C:
@trace
@classmethod
def f(cls, x): return x * 3
# класс определился молча
C.f(3) # TypeError: 'classmethod' object is not callableПричина в одной строке документации: Changed in version 3.10: Static methods
are now callable
(Изменено в версии 3.10: статические методы теперь вызываемы). У classmethod такой строки нет — объект класс-метода
вызвать нельзя, и trace спотыкается о попытку f(*a, **kw) внутри обёртки.
Момент, когда это происходит, стоит запомнить отдельно: не на определении
класса, а при первом вызове. Тело класса отрабатывает без единой жалобы —
functools.wraps копирует атрибуты через try/except и на дескрипторе не
спотыкается. Поэтому импорт модуля проходит, тесты, которые только импортируют,
проходят тоже, а падает первый настоящий вызов.
Правило, которое отсюда следует, простое и не требует помнить исключения:
@staticmethod и @classmethod ставятся самыми верхними. Тогда чужой
декоратор получает обычную функцию, а дескриптор строится поверх результата:
class B:
@staticmethod
@trace
def f(x): return x * 2
B.f(3) # 6Глубже: @classmethod над @property
В 3.9 classmethod научили оборачивать другие дескрипторы, в 3.11 это
объявили устаревшим, в 3.13 убрали. Убрали так, что код не падает:
class A:
@classmethod
@property
def v(cls): return "значение"
print(A.v)| версия | что печатает |
|---|---|
| 3.11 | 'значение' |
| 3.12 | 'значение' |
| 3.13 | <bound method v of <class '__main__.A'>> |
| 3.14 | <bound method v of <class '__main__.A'>> |
Исключения нет. Есть значение другого типа, которое дальше уедет в f-строку, в JSON или в сравнение — и проявится там, а не здесь. Это худший вид поломки при обновлении версии, и единственная защита от него — не строить цепочки из встроенных дескрипторов.
Глубже: сколько стоит слой
Обёртка на *args, **kwargs — это дополнительный вызов Python-функции и
упаковка аргументов в кортеж и словарь. Замер на
3.13.7, лучшее из ста чередующихся кругов по 20 000 вызовов:
На шкале акцентом отмечена работа самой функции — видно, как она превращается в узкую полоску слева.
Те же числа цифрами:
| что вызываем | наносекунд на вызов |
|---|---|
| функция без декораторов | 17,3 |
| один слой | 53,5 |
| два слоя | 89,7 |
| три слоя | 125,6 |
Каждый слой добавляет около 36 наносекунд. Для эндпоинта, который ходит в базу, это шум; для функции в горячем цикле три декоратора означают, что почти семь восьмых времени уходит не на работу, а на подход к ней. На 3.14.7 та же линейность: 16,5 — 59,6 — 101,7 — 145,2. Сравнивать эту четвёрку с предыдущей нельзя: сборки различаются не только компилятором. Сравнивать имеет смысл соседей внутри одной четвёрки — и там линейность одинаковая.
Отсюда же видно, что даёт C-обёртка functools.lru_cache: кеш, который стоит
дороже вычисления, бесполезен. Оговорка: питоновская реализация в
Lib/functools.py тоже есть, и C-версия из _functools подменяет её в конце
модуля.
Глубже: история версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 2.4 | PEP 318: синтаксис @. До него преобразование записывалось после тела функции — places the actual transformation after the function body (помещает само преобразование после тела функции), и у длинной функции оказывалось далеко от её интерфейса. | Появился сам механизм |
| 3.9 | PEP 614: после @ допустимо любое выражение. Раньше грамматика требовала имя с точками (модуль.объект.атрибут) и необязательный вызов. | Можно @obj[0].method |
| 3.9 | classmethod научился оборачивать другие дескрипторы, включая property. | Появилось, чтобы исчезнуть |
| 3.10 | staticmethod стал вызываемым напрямую и получил __wrapped__. С этого момента свой декоратор поверх @staticmethod перестал падать — но только он. | Асимметрия со classmethod |
| 3.11 | Оборачивание дескрипторов класс-методом объявлено устаревшим. | Предупреждение |
| 3.12 | В WRAPPER_ASSIGNMENTS добавлен __type_params__ — параметры типа из нового синтаксиса обобщений (PEP 695) тоже переносятся на обёртку. | wraps стал полнее |
| 3.13 | Оборачивание дескрипторов класс-методом удалено. Кода это не роняет: A.v начинает возвращать связанный метод вместо значения. | Молчаливая смена типа |
| 3.14 | В WRAPPER_ASSIGNMENTS на месте __annotations__ теперь __annotate__ (PEP 649): копируется функция вычисления аннотаций, а не готовый словарь. | Отложенные аннотации |
Как отвечать на собеседовании
Короткий ответ: @d над def f — это f = d(f), выполненное один раз, в
момент, когда интерпретатор дошёл до def. Когда декораторов несколько,
выражения после @ вычисляются сверху вниз, а применяются снизу вверх.
functools.wraps делает обёртку похожей на цель, а не равной ей: сигнатуру
цели показывает inspect, идущий по __wrapped__, а сама обёртка
по-прежнему принимает *args, **kwargs.
Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.
Если интервьюер копает глубже
Что отличает хороший ответ: назвать две фазы порознь — вычисление и
применение идут в разные стороны, и стоит их перепутать, как разъезжается
весь ответ. И сказать, зачем
wraps нужен на самом деле: не ради help(), а ради pickle и
multiprocessing, которые ищут функцию по паре __module__ и __qualname__,
— у обёртки без wraps это путь внутрь замыкания, по которому ничего не
найти.
Дальше спросят
А декоратор с аргументами — это другой механизм?
Тот же. После @ стоит выражение, его результат вызывается с функцией; если
выражение само является вызовом, вызовов просто становится два — сначала
фабрика, потом полученный ею декоратор.
Свой декоратор поверх @classmethod — что сломается?
classmethod и staticmethod возвращают дескрипторы, а не функции: обёртка
получает объект, который вмешивается в обращение к атрибуту, а не вызываемую
функцию. Поведение при этом асимметрично между этими двумя, и асимметрия не
выводится из общих соображений — она проверяется запуском.
Частые заблуждения
«Декоратор выполняется при каждом вызове функции».
При каждом вызове выполняется ОБЁРТКА, которую декоратор вернул. Сам декоратор отработал один раз, на def. Разницу видно на счётчике: тело декоратора добавляет строку в журнал ровно однажды, сколько бы раз функцию потом ни звали. Практическое следствие — не про производительность, а про побочные эффекты: чтение переменной окружения внутри декоратора произойдёт при импорте модуля.
«Декораторы применяются сверху вниз».
Применяются снизу вверх, а вот ВЫЧИСЛЯЮТСЯ выражения сверху вниз — это две разные фазы, и обе наблюдаемы. Опыт с двумя фабриками печатает: «вычислено выражение верхний», «вычислено выражение нижний», «применён нижний», «применён верхний». В байткоде 3.13 то же самое: LOAD_NAME d1 идёт раньше вычисления d2(arg), а два CALL — в обратном порядке.
«functools.wraps делает обёртку неотличимой от исходной функции».
Он копирует шесть атрибутов (на 3.11 — пять: __type_params__ добавлен в 3.12) и ставит __wrapped__. Сигнатуру он не меняет: inspect.signature(c) показывает сигнатуру цели только потому, что inspect сам идёт по __wrapped__. Достаточно попросить не идти — inspect.signature(c, follow_wrapped=False) — и снова видно (*a, **kw). Обёртка по-прежнему принимает что угодно: лишний именованный аргумент отвергнет не граница обёртки, а сама цель — и уже после того, как он до неё доехал.
«wraps — это косметика для help()».
Он же чинит адресуемость функции. pickle ищет объект по __module__ и __qualname__; у обёртки без wraps это naive.<locals>.wrapper, и сериализация падает — AttributeError на 3.11–3.13, PicklingError на 3.14. С wraps — проходит. От этого зависят multiprocessing и очереди задач, передающие функцию по имени.
«Порядок @staticmethod и своего декоратора не важен, лишь бы оба были».
Важен, и по-разному для двух похожих встроенных. @trace поверх @staticmethod работает начиная с 3.10 (Static methods are now callable
(статические методы теперь вызываемы)), а @trace поверх @classmethod падает с TypeError: 'classmethod' object is not callable — проверено на 3.11, 3.12, 3.13 и 3.14. Правило без исключений: @staticmethod и @classmethod ставятся самыми верхними.
«Декораторы бесплатны: обёртка — это же просто вызов».
Просто вызов и стоит как вызов. Замер на 3.13.7: 17,3 нс без декораторов, 53,5 с одним слоем, 125,6 с тремя. Каждый слой — около 36 нс на упаковку *args/**kwargs и лишний кадр. В эндпоинте с походом в базу это незаметно; в горячем цикле три слоя означают, что большая часть времени уходит на подход к работе, а не на работу.
«Устаревшее в Python убирают с предупреждением, поэтому обновление версии безопасно».
Не всегда. @classmethod над @property работал в 3.9–3.12, объявлен устаревшим в 3.11 и удалён в 3.13 — но обращение A.v в 3.13 не бросает исключение, а возвращает <bound method> вместо значения. Тип изменился молча, и проявится это ниже по стеку: в f-строке, в JSON, в сравнении.
Практика
Две задачи. Сначала ответьте, потом сверьтесь с настоящим выводом: в обеих правильный ответ взят из прогона скрипта, а не назначен.
Практика · что напечатает
import functools
import inspect
def deco(fn):
@functools.wraps(fn)
def wrapper(*a, **kw):
return fn(*a, **kw)
return wrapper
def target(x: int, y: str = "s") -> bool:
return True
three = deco(deco(deco(target)))
print(inspect.signature(three))
print(inspect.signature(three, follow_wrapped=False))Практика · оцените
Проверка знаний
Модуль импортируется ради одной функции. В файле есть декоратор, который при применении читает переменную окружения и открывает соединение. Когда это произойдёт?
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона: на чём считали, что получилось и с каким разбросом.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
@dнадdef f— это не объявление и не магия. Этоf = d(f): функцию передали декоратору, а результатом заменили имя. Присваивание случается один раз, в тот момент, когда интерпретатор дошёл доdef. При вызове работает уже обёртка, которую декоратор вернул; сам декоратор больше не стоит ничего — он отработал.- Отсюда главное следствие: когда декораторов несколько, выражения вычисляются сверху вниз, а применяются снизу вверх. Из этой пары растут почти все неожиданности: и порядок обёрток, и то, почему
@classmethodпод своим декоратором ломается не при определении класса, а при первом вызове, и почемуfunctools.wraps— не вежливость, а условие работоспособности отладчика, документации иpickle. - Дальше — числа, версии и границы.
wrapsделает обёртку похожей на цель, а не равной ей:inspect.signatureпоказывает сигнатуру цели только потому, что идёт по__wrapped__, — сама обёртка по-прежнему принимает*args, **kwargs. Слой стоит измеримо: 17,3 нс без декораторов против 53,5 с одним слоем на 3.13.7, около 36 нс за слой. А встроенные декораторы зависят от версии: объектstaticmethodвызываем с 3.10, объектclassmethod— нет, и@classmethodнад@propertyс 3.13 возвращает не значение, а связанный метод.
На самом деле
- При каждом вызове выполняется ОБЁРТКА, которую декоратор вернул. Сам декоратор отработал один раз, на
def. Разницу видно на счётчике: тело декоратора добавляет строку в журнал ровно однажды, сколько бы раз функцию потом ни звали. Практическое следствие — не про производительность, а про побочные эффекты: чтение переменной окружения внутри декоратора произойдёт при импорте модуля. - Применяются снизу вверх, а вот ВЫЧИСЛЯЮТСЯ выражения сверху вниз — это две разные фазы, и обе наблюдаемы. Опыт с двумя фабриками печатает: «вычислено выражение верхний», «вычислено выражение нижний», «применён нижний», «применён верхний». В байткоде 3.13 то же самое:
LOAD_NAME d1идёт раньше вычисленияd2(arg), а дваCALL— в обратном порядке. - Он копирует шесть атрибутов (на 3.11 — пять:
__type_params__добавлен в 3.12) и ставит__wrapped__. Сигнатуру он не меняет:inspect.signature(c)показывает сигнатуру цели только потому, чтоinspectсам идёт по__wrapped__. Достаточно попросить не идти —inspect.signature(c, follow_wrapped=False)— и снова видно(*a, **kw). Обёртка по-прежнему принимает что угодно: лишний именованный аргумент отвергнет не граница обёртки, а сама цель — и уже после того, как он до неё доехал. - Он же чинит адресуемость функции.
pickleищет объект по__module__и__qualname__; у обёртки безwrapsэтоnaive.<locals>.wrapper, и сериализация падает —AttributeErrorна 3.11–3.13,PicklingErrorна 3.14. Сwraps— проходит. От этого зависятmultiprocessingи очереди задач, передающие функцию по имени. - Важен, и по-разному для двух похожих встроенных.
@traceповерх@staticmethodработает начиная с 3.10 (Static methods are now callable), а@traceповерх@classmethodпадает сTypeError: 'classmethod' object is not callable— проверено на 3.11, 3.12, 3.13 и 3.14. Правило без исключений:@staticmethodи@classmethodставятся самыми верхними. - Просто вызов и стоит как вызов. Замер на 3.13.7: 17,3 нс без декораторов, 53,5 с одним слоем, 125,6 с тремя. Каждый слой — около 36 нс на упаковку
*args/**kwargsи лишний кадр. В эндпоинте с походом в базу это незаметно; в горячем цикле три слоя означают, что большая часть времени уходит на подход к работе, а не на работу. - Не всегда.
@classmethodнад@propertyработал в 3.9–3.12, объявлен устаревшим в 3.11 и удалён в 3.13 — но обращениеA.vв 3.13 не бросает исключение, а возвращает<bound method>вместо значения. Тип изменился молча, и проявится это ниже по стеку: в f-строке, в JSON, в сравнении.
По версиям
- 2.4
- PEP 318: синтаксис
@. До него преобразование записывалось после тела функции — places the actual transformation after the function body, и у длинной функции оказывалось далеко от её интерфейса.< - 3.9
- PEP 614: после
@допустимо любое выражение. Раньше грамматика требовала имя с точками (модуль.объект.атрибут) и необязательный вызов.< - 3.9
classmethodнаучился оборачивать другие дескрипторы, включаяproperty.<- 3.10
staticmethodстал вызываемым напрямую и получил__wrapped__. С этого момента свой декоратор поверх@staticmethodперестал падать — но только он.<- 3.11
- Оборачивание дескрипторов класс-методом объявлено устаревшим.<
- 3.12
- В
WRAPPER_ASSIGNMENTSдобавлен__type_params__— параметры типа из нового синтаксиса обобщений (PEP 695) тоже переносятся на обёртку.< - 3.13
- Оборачивание дескрипторов класс-методом удалено. Кода это не роняет:
A.vначинает возвращать связанный метод вместо значения.< - 3.14
- В
WRAPPER_ASSIGNMENTSна месте__annotations__теперь__annotate__(PEP 649): копируется функция вычисления аннотаций, а не готовый словарь.<
Что разобрано
- База: что делает знак `@`
- Механизм 1: один вызов в момент определения
- Механизм 2: сверху вниз, снизу вверх
- Механизм 3: имя не связывается по дороге
- Механизм 4: декоратор с аргументами — это два вызова
- Механизм 5: что делает `functools.wraps` и чего не делает
- Глубже: снять слои — `inspect.unwrap` и подделка сигнатуры
- Глубже: асинхронный декоратор и корутинность
- Глубже: свой декоратор поверх `staticmethod` и `classmethod`
- Глубже: `@classmethod` над `@property`
- Глубже: сколько стоит слой
- Глубже: история версий
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
- Чем измерено
Источники и что читать дальше
9 ИСТОЧНИКОВ
- PEP 318 — Decorators for Functions and MethodsPEP. Статус Final, Python 2.4, создан 05.06.2003. Отсюда и синтаксис, и объяснение порядка применения: «The rationale for the order of application (bottom to top) is that it matches the usual order for function-application» (Порядок применения (снизу вверх) выбран потому, что он совпадает с обычным порядком применения функций).https://peps.python.org/pep-0318/
- PEP 614 — Relaxing Grammar Restrictions On DecoratorsPEP. Статус Final, Python 3.9. Старая грамматика: `decorator: '@' dotted_name [ '(' [arglist] ')' ] NEWLINE`; новая — `'@' namedexpr_test NEWLINE`. Отсюда версия, начиная с которой после @ допустимо любое выражение.https://peps.python.org/pep-0614/
- Справочник языка — определения функцийОфициальная документация. Определение семантики целиком: «Decorator expressions are evaluated when the function is defined, in the scope that contains the function definition» (Выражения-декораторы вычисляются в момент определения функции, в области видимости, содержащей это определение) и оговорка, из-за которой эквивалентность неточна: «except that the original function is not temporarily bound to the name func» (с той разницей, что исходная функция не связывается временно с именем func).https://docs.python.org/3.13/reference/compound_stmts.html#function-definitions
- functools — update_wrapper и wrapsОфициальная документация. Перечень копируемых атрибутов, автоматическая установка `__wrapped__` «to allow access to the original function for introspection» (чтобы дать доступ к исходной функции для интроспекции) и предупреждение о том, что без обёртки «the metadata of the returned function will reflect the wrapper definition rather than the original» (метаданные возвращённой функции будут описывать обёртку, а не исходную функцию).https://docs.python.org/3.13/library/functools.html
- Встроенные функции — staticmethod и classmethodОфициальная документация. «Changed in version 3.10: Static methods are now callable» (Изменено в версии 3.10: статические методы теперь вызываемы) — и рядом, у classmethod, отсутствие такой же строки. Там же: «Deprecated since version 3.11, removed in version 3.13: Class methods can no longer wrap other descriptors such as property()» (Устарело с версии 3.11, удалено в версии 3.13: методы класса больше не могут оборачивать другие дескрипторы, такие как property()).https://docs.python.org/3.13/library/functions.html
- Lib/functools.py — WRAPPER_ASSIGNMENTS и update_wrapperИсходный код CPython. Строки 34–35: перечень копируемых атрибутов. Строки 45–50: копирование через try/except AttributeError — отсутствующий у цели атрибут молча пропускается. Строка, ставящая `__wrapped__`, — в конце той же функции. Тег CPython 3.13.7.https://github.com/python/cpython/blob/v3.13.7/Lib/functools.py
- Lib/functools.py в 3.14 — тот же список после PEP 649Исходный код CPython. Строки 36–37: на месте `__annotations__` теперь `__annotate__`. Это не косметика: копируется функция вычисления аннотаций, а не готовый словарь, потому что в 3.14 аннотации вычисляются отложенно. Тег CPython 3.14.0.https://github.com/python/cpython/blob/v3.14.0/Lib/functools.py
- PEP 649 — Deferred Evaluation Of Annotations Using DescriptorsPEP. Документ, из-за которого в 3.14 в WRAPPER_ASSIGNMENTS появился `__annotate__`.https://peps.python.org/pep-0649/
- inspect — unwrap, signature, markcoroutinefunctionОфициальная документация. Три места, на которых стоят разделы про снятие слоёв и про асинхронный декоратор. Про раскрутку: «It follows the chain of `__wrapped__` attributes returning the last object in the chain» (Функция идёт по цепочке атрибутов `__wrapped__` и возвращает последний объект цепочки). Про подделку сигнатуры: «If the passed object has a `__signature__` attribute, we may use it to create the signature. The exact semantics are an implementation detail» (Если у переданного объекта есть атрибут `__signature__`, мы можем использовать его для построения сигнатуры. Точная семантика — деталь реализации) — формулировка сверена дословно на 3.11, 3.12, 3.13 и 3.14 и одинакова во всех четырёх. Про маркер: «Decorator to mark a callable as a coroutine function if it would not otherwise be detected by `iscoroutinefunction`… When possible, using an `async def` function is preferred» (Декоратор, помечающий вызываемый объект как корутинную функцию, если иначе `iscoroutinefunction` её не распознаёт… Когда возможно, предпочтительна функция `async def`), добавлено в 3.12.https://docs.python.org/3.14/library/inspect.html