Функция — объект, замыкание захватывает ячейку, а декоратор — просто присваивание. Три факта, из которых выводятся позднее связывание, потеря сигнатуры, тихая утечка через lru_cache и то, почему @property устроен так же, как @staticmethod.
Зафиксируй вывод и причину до того, как откроешь ответ.
fs = [lambda: i for i in range(3)]
print([f() for f in fs])
fs2 = [lambda i=i: i for i in range(3)]
print([f() for f in fs2])[2, 2, 2]
[0, 1, 2]Одно и то же выражение, разница в одном дефолтном аргументе.def deco(f):
def w(*a, **k): return f(*a, **k)
return w
@deco
def target(x: int, y: str = "a") -> bool:
"док"
return True
import inspect
print(target.__name__, target.__doc__)
print(inspect.signature(target))w None
(*a, **k)Имя, документация и сигнатура — всё чужое.def counter():
n = 0
def inc():
nonlocal n
n += 1
return n
return inc
c = counter()
c(); c()
print(c.__code__.co_freevars, len(c.__closure__))('n',) 1У функции есть поле, которого нет у обычной функции. Что в нём лежит?from functools import lru_cache
class Heavy:
def __init__(self): self.buf = bytes(200_000)
@lru_cache(maxsize=None)
def calc(self, x): return x * 2
import weakref, gc
h = Heavy(); w = weakref.ref(h)
h.calc(1)
del h; gc.collect()
print(w() is not None)TrueОбъект удалили, сборщик отработал, объект жив.Три факта, каждый из которых по отдельности звучит безобидно. Вместе они объясняют всё содержимое этого урока.
Функция — объект. У неё есть атрибуты, её можно передать, положить в список, приписать ей поле. Инструкция def исполняется и создаёт этот объект — как уже было в уроке 01.
Замыкание захватывает ячейку, а не значение. Внутренняя функция, использующая имя из внешней, получает ссылку на общую ячейку; в момент вызова она читает то, что в ячейке лежит сейчас.
Декоратор — синтаксис для присваивания. @deco над def f означает буквально f = deco(f). Никакой отдельной сущности «декоратор» в языке нет.
Фрагмент A. Все три лямбды ссылаются на одну ячейку переменной цикла. К моменту вызова цикл давно закончился, и в ячейке лежит 2. Ничего не «сломалось» — функции честно прочитали текущее значение, как и обещано определением.
Вариант с lambda i=i работает по другому механизму: значение по умолчанию вычисляется в момент создания функции (урок 01, следствие 3) и складывается в __defaults__. То есть починка происходит не через замыкание, а в обход него.
Фрагмент C показывает саму ячейку. co_freevars перечисляет захваченные имена, __closure__ хранит ячейки — у обычной функции этого поля попросту нет.
Раз @deco — это f = deco(f), наружу выходит другой объект. У него своё имя, своя документация, своя сигнатура. Исходная функция никуда не делась, но по имени target теперь доступна обёртка.
Фрагмент B — прямое следствие, а не недоработка. functools.wraps копирует метаданные с оригинала на обёртку и дополнительно кладёт ссылку в __wrapped__, по которой inspect.signature находит настоящую сигнатуру.
Отсюда правило без исключений: обёртка без wraps ломает всё, что читает метаданные функции — справку, отладчик, документацию, диспетчеризацию по типам и любой фреймворк, строящий поведение по аннотациям.
@deco(x) раскрывается в f = deco(x)(f): сначала вызывается deco(x), и уже его результат применяется к функции. Значит уровней вложенности ровно три — принимающий настройки, принимающий функцию, принимающий вызов.
Это не соглашение, а арифметика раскрытия синтаксиса. И отсюда же понятно, почему @deco без скобок и @deco() со скобками — разные вещи, а библиотеки, поддерживающие обе формы, делают это ветвлением внутри.
Фрагмент D. @lru_cache — обычный декоратор, применённый в теле класса, поэтому кэш живёт на функции, то есть на классе. Ключом служит кортеж аргументов, а первый аргумент метода — self. Значит кэш держит сильную ссылку на каждый экземпляр, для которого метод вызывался.
Из урока 09 известно, что объект жив, пока на него есть ссылка. Кэш её и держит — до вытеснения, а при maxsize=None вытеснения нет вообще. Тихая утечка на долгоживущем процессе, которую видно только по росту памяти.
@property, @staticmethod, @classmethod — обычные декораторы. Они не встроены в синтаксис: это функции, возвращающие объект-дескриптор, который дальше работает по процедуре из урока 04. Три «магические» конструкции оказываются одним механизмом — декоратор плюс дескриптор — и запоминать их по отдельности не нужно.
nonlocal и global — разные вещи. Первый привязывает имя к ячейке ближайшей внешней функции, второй — к модулю. Без них присваивание внутри функции создаёт новую локальную переменную, и чтение до присваивания даёт UnboundLocalError, а не значение снаружи.
Замыкание удерживает всё, на что смотрит. Ячейка держит объект, объект держит свой граф — механизм из урока 09 работает и здесь. Замыкание, случайно захватившее self или большой кадр данных, удержит его столько, сколько живёт функция.
wraps копирует не всё. Он переносит имя, документацию, модуль, аннотации и __dict__, но обёртка остаётся другим объектом: id другой, а inspect.signature честен только потому, что специально смотрит в __wrapped__. Код, сравнивающий функции по идентичности, всё равно сломается.
Корень R2 в его самой прямой форме: функция — такой же объект, как всё остальное, и имя к ней просто привязано. Из этого механически следуют и декораторы, и замыкания, и то, что @property не нуждается в поддержке синтаксиса.
Порядок появления показателен. Замыкания и вложенные функции были почти с начала; синтаксис @ добавили только в 2.4 (PEP 318) — до этого писали f = deco(f) руками, и это работало точно так же. То есть декоратор появился как сокращение записи для уже существующего механизма, а не как новая возможность. Отсюда же nonlocal, добавленный лишь в Python 3: до него замыкание могло читать внешнюю переменную, но не присваивать ей, и обходились изменяемым списком из одного элемента.
Замыкание в Python — это лямбда C++ с захватом по ссылке, и аналогия точная вплоть до последствий. Ячейка играет роль захваченной ссылки: обе смотрят на переменную, а не на её значение, обе видят изменения, сделанные после создания, обе дают «неожиданный» результат в цикле.
Расходятся они в одном месте, и это тоже поучительно. В C++ захват по ссылке может пережить переменную и превратиться в висячую ссылку — классический источник неопределённого поведения. В Python ячейка удерживает объект живым, потому что действует счётчик ссылок из урока 09. Тот же приём, но одна модель памяти делает его опасным, а другая — просто контринтуитивным.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Потому что захватывается ячейка, а не содержимое, — и это то же самое решение, что делает nonlocal возможным.
Классическая ловушка:
fs = [lambda: i for i in range(3)]
print([f() for f in fs]) # → [2, 2, 2]
fs = [lambda i=i: i for i in range(3)]
print([f() for f in fs]) # → [0, 1, 2]
Все три лямбды смотрят в одну ячейку, и к моменту вызова там лежит последнее значение. Второй вариант работает потому, что дефолт вычисляется в момент выполнения lambda — то самое раннее связывание из урока 01, здесь применённое по назначению.
Почему не сделали захват по значению. Тогда стало бы невозможно замыкание, которое меняет внешнюю переменную, — счётчики, аккумуляторы, декораторы с состоянием. Пришлось бы вводить два разных синтаксиса захвата, как в C++ ([=] и [&]). Python выбрал одно правило и явный обходной путь через дефолт.
Видно это прямо в code object:
def outer():
n = 0
def inner(): return n
return inner
print(outer().__code__.co_freevars) # → ('n',)
print(outer().__closure__[0].cell_contents) # → 0
cell_contents — содержимое ячейки, а сама ячейка одна на всех, кто её захватил.
functools.wraps, если декоратор и без него работает?Работает — до первой встречи с интроспекцией, а на ней стоит половина инструментов.
Без wraps декоратор подменяет объект функции, и наружу торчит обёртка со своим именем, своей докстрокой и сигнатурой (*a, **k):
import functools, inspect
def bare(fn):
def w(*a, **k): return fn(*a, **k)
return w
def kept(fn):
@functools.wraps(fn)
def w(*a, **k): return fn(*a, **k)
return w
@bare
def f(x: int) -> int:
"складывает"
return x
@kept
def g(x: int) -> int:
"складывает"
return x
print(f.__name__, f.__doc__, inspect.signature(f))
# → w None (*a, **k)
print(g.__name__, g.__doc__, inspect.signature(g))
# → g складывает (x: int) -> int
Что ломается по-настоящему: сообщения об ошибках и логи показывают w вместо имени функции; help() и Sphinx теряют документацию; pytest путается в фикстурах; typing.get_type_hints не находит аннотаций; кеши и диспетчеры, ключующиеся по __qualname__, склеивают разные функции в одну.
Плюс wraps сохраняет __wrapped__ — по нему inspect добирается до исходной функции сквозь любое число слоёв. Это единственная причина, по которой inspect.signature в примере выше вернула правильную сигнатуру.
lru_cache на методе — это утечка?Потому что кеш живёт на классе, а ключом в него попадает self.
Декоратор применяется к функции в теле класса, то есть один раз на класс, а не на инстанс. Первый аргумент метода — сам объект, значит он оказывается частью ключа и удерживается кешем ссылкой. Инстанс не умрёт, пока кеш не вытеснит его запись, а при maxsize=None — никогда.
import functools, gc, weakref
class Heavy:
def __init__(self): self.data = bytearray(10**6)
@functools.lru_cache(maxsize=None)
def compute(self, k): return k * 2
h = Heavy()
r = weakref.ref(h)
h.compute(1)
del h
gc.collect()
print(r()) # → объект жив, хотя ссылок в коде нет
В проде это выглядит как сервис, у которого память растёт линейно с числом обработанных объектов, и профилировщик честно показывает их достижимыми — потому что они достижимы.
Что делать. Кешировать свободную функцию, принимающую только неизменяемые аргументы, и звать её из метода. Или functools.cached_property, если значение зависит только от объекта, — там кеш лежит в самом объекте и умирает вместе с ним. Или явный словарь на инстансе. Для методов, где ключ — только аргументы, а не состояние, подойдёт @staticmethod с lru_cache.
С 3.8 существует functools.cached_property, а в 3.12 добавили предупреждение о такой ошибке в документацию — но не в код: язык её по-прежнему не ловит.
Потому что @dec(arg) — это не особый синтаксис, а сначала вызов, потом декорирование.
Строка @retry(3) разворачивается в f = retry(3)(f). То есть retry(3) обязана вернуть декоратор, а он уже вернёт обёртку. Отсюда ровно три уровня: параметры → функция → аргументы вызова.
import functools
def retry(times): # 1. принимает параметры
def decorator(fn): # 2. принимает функцию
@functools.wraps(fn)
def wrapper(*a, **k): # 3. принимает аргументы вызова
for attempt in range(times):
try: return fn(*a, **k)
except Exception:
if attempt == times - 1: raise
return wrapper
return decorator
@retry(3)
def flaky(): ...
Отсюда же самая частая ошибка — декоратор, который хочет работать и с аргументами, и без: @retry и @retry(3) одновременно. Это требует различать «мне передали функцию» и «мне передали параметр», и обычно пишется через проверку callable(первый аргумент). Читается плохо; проще не поддерживать обе формы.
Более чистая альтернатива, если вложенность мешает, — класс с __call__: параметры лежат в __init__, обёртка в __call__, уровней два. Но тогда придётся руками восстанавливать __name__ и __doc__, и на методах класс-декоратор ведёт себя иначе, потому что не является дескриптором.
def; замыкание захватывает ячейку, а не значение; декоратор — синтаксис для f = deco(f). Из первого следует, что функции можно передавать и приписывать им поля. Из второго — позднее связывание: все лямбды в цикле смотрят в одну ячейку и читают её в момент вызова, а приём lambda i=i чинит это в обход замыкания, через вычисление дефолта при создании. Из третьего — что наружу выходит другой объект со своими именем, документацией и сигнатурой, и без functools.wraps ломается всё, что читает метаданные: справка, отладчик, диспетчеризация, фреймворки на аннотациях. Оттуда же lru_cache на методе: кэш живёт на классе и держит self ключом, то есть не даёт экземплярам умереть. И @property, @staticmethod, @classmethod — не синтаксис, а те же декораторы, возвращающие дескрипторы из урока 04.C++ — самое точное сравнение, потому что там выбор захвата явный. [=] копирует значение в момент создания лямбды, [&] захватывает по ссылке. Python всегда делает второе: захват по ячейке, то есть [&] без альтернативы. Отсюда и позднее связывание из следствия 1 — в C++ ровно то же поведение получается при [&], и ровно так же ловит начинающих, только там ещё и ссылка может пережить область видимости и стать висячей. Python от последнего защищён счётчиком ссылок: ячейка держит объект живым.
Приём lambda i=i: i — это ручная эмуляция [=]: скопировать значение в момент создания. Зная C++, конструкция перестаёт выглядеть трюком.
JavaScript прошёл через ту же боль и решил её на уровне языка: var вёл себя как Python, let в цикле создаёт новую привязку на каждой итерации. Python такого не сделал — и не сделает, из-за R9.
Java потребовала, чтобы захватываемая переменная была фактически финальной. Проблема устранена запретом ситуации — тот же приём, что с множественным наследованием.
(*a, **k) — валидация по аннотациям и внедрение зависимостей перестают работатьНаписали свой декоратор для логирования. Эндпоинты перестали получать параметры.
def logged(f):
def w(*a, **k):
log.info("вызов %s", f.__name__)
return f(*a, **k)
return w
@app.get("/items/{item_id}")
@logged
def read_item(item_id: int, q: str | None = None): ...
Фреймворк строит маршрут, читая сигнатуру функции: какие параметры из пути, какие из query, какие типы приводить. Но видит он не read_item, а обёртку:
print(inspect.signature(read_item))
# → (*a, **k)
print(read_item.__name__)
# → 'w'
Дальше поведение зависит от фреймворка: где-то ошибка при старте, где-то параметры молча приходят пустыми, где-то в документации API появляется эндпоинт с аргументами a и k. Ревью пропускает, потому что декоратор написан по канону и в юнит-тесте на саму функцию работает — ломается только связка с тем, кто читает метаданные.
import functools
def logged(f):
@functools.wraps(f)
def w(*a, **k):
log.info("вызов %s", f.__name__)
return f(*a, **k)
return w
Правило из следствия 2, без исключений: любая обёртка вокруг функции получает @functools.wraps. Стоит одну строку, а её отсутствие проявляется в самых неожиданных местах — от пустой строки в трейсбеке до сломанной OpenAPI-схемы.
Две стороны одной медали: за декоратор платят на каждом вызове, но иногда он возвращает в тысячи раз больше.
Цена обёртки. Тривиальная функция, вызванная напрямую и через простейший декоратор:
| 2 000 000 вызовов | время |
|---|---|
| напрямую | 0.109 с |
| через обёртку | 0.257 с |
В два с половиной раза — потому что вместо одного вызова происходят два, плюс распаковка *a, **k. На функции, делающей реальную работу, это теряется в шуме; на микрофункции в горячем цикле — нет. Практический вывод: не декорировать то, что вызывается миллионы раз и само по себе стоит наносекунды.
Когда обёртка окупается. Та же механика, но с кэшем, на рекурсивной функции:
| fib(25), 20 прогонов | время |
|---|---|
| без кэша | 0.202 с |
с @lru_cache после прогрева | 0.000004 с |
Обёртка стоит те же наносекунды, но заменяет экспоненциальное дерево вызовов одним поиском в словаре. Это не оптимизация функции, а смена класса сложности — как выбор структуры данных в уроке 03.
Два условия применимости, оба выводятся из механизма: аргументы должны быть хешируемыми (урок 05), и кэш нельзя вешать на методы — следствие 4 показывает, во что это обходится. Для методов есть cached_property для неизменяемых объектов или явный кэш в самом экземпляре.
Прогони четыре фрагмента, сверяясь с предсказанием. Затем:
x = 1
def f():
print(x)
x = 2
f()
# Почему UnboundLocalError, если print идёт первым?
def a(f): print("a"); return f
def b(f): print("b"); return f
@a
@b
def g(): pass
# В каком порядке напечатается и почему именно так?
def make():
data = list(range(1_000_000))
return lambda: len(data)
f = make()
# Сколько памяти удерживает f? А если вернуть lambda: 0?
property.__get__, staticmethod.__get__
# Убедись, что оба — дескрипторы. Что это доказывает?
Первый вопрос — самый полезный: имя становится локальным на этапе компиляции функции, а не в момент присваивания, и это видно в f.__code__.co_varnames.
Lib/functools.py, wraps и update_wrapper. Двадцать строк, которые стоит прочитать целиком: список копируемых атрибутов задан явными кортежами WRAPPER_ASSIGNMENTS и WRAPPER_UPDATES, а в конце — присваивание wrapper.__wrapped__ = wrapped. Именно эта строка позволяет inspect.signature добраться до оригинала. Заметь, что механизма «наследовать метаданные» в языке нет — всё делается копированием руками.
Lib/dataclasses.py. Здесь самое интересное: чтобы сгенерировать __init__ с правильной сигнатурой, стандартная библиотека собирает исходный текст функции строкой и выполняет exec. Причина прямо в следствии 2 — обёртка вида def __init__(*a, **k) имела бы неправильную сигнатуру, а подделать её нельзя; настоящую сигнатуру даёт только настоящий def. Заодно видно, что dataclass — декоратор класса, то есть самый лёгкий уровень вмешательства из урока 07.
Из L1 ты знаешь про ячейку. Здесь — кто её создаёт и когда.
Решение принимает компилятор, а не рантайм. Разбирая тело функции, он классифицирует каждое имя: локальное, свободное (используется здесь, определено во внешней функции), ячеечное (определено здесь, используется во вложенной) или глобальное. Результат лежит в объекте кода:
print(c.__code__.co_freevars) # что захвачено
# → ('n',)
print(counter.__code__.co_cellvars) # что отдано вложенным
# → ('n',)
print(c.__closure__[0].cell_contents)
# → 2
Отсюда ответ на вопрос про UnboundLocalError: раз имя классифицируется при компиляции, присваивание где угодно в теле делает имя локальным на всём протяжении функции, включая строки до присваивания.
Создание функции — инструкция MAKE_FUNCTION, которая при необходимости прикрепляет кортеж ячеек. Это значит, что каждое исполнение def создаёт новый объект функции: вложенная функция, определённая в цикле, — это N объектов, а не один. Отсюда и накладные расходы на фабрики функций в горячем коде.
Про стоимость вызова: разница 0.109 с против 0.257 с из раздела «На практике» складывается из второго кадра вызова, упаковки аргументов в кортеж и словарь и распаковки обратно. С 3.11 кадры стали дешевле, поэтому на новых версиях разрыв меньше — проверять стоит на своей.
Тег v3.11.15.
Objects/cellobject.c — весь тип ячейки, полтораста строк. Он устроен предельно просто: один указатель, и это буквально «ссылка на объект», о которой шла речь в уроке 01.Python/symtable.c — классификация имён при компиляции: локальное, свободное, ячеечное, глобальное. Здесь принимается решение, порождающее UnboundLocalError.Objects/funcobject.c, func_new_impl — из чего состоит объект функции: код, глобальные, дефолты, замыкание, аннотации.@ в 2.4; в мотивации прямо сказано, что механизм уже существовал и добавляется только запись.[=] против [&], выбор есть — в Python его нетlet в JavaScript: та же проблема, решённая изменением языкаLib/dataclasses.py, функция _create_fn — читать целиком. Двадцать строк, объясняющие, почему настоящую сигнатуру можно получить только настоящим def.functools.wraps — выборочно: список копируемых атрибутов и абзац про __wrapped__.