Функции, замыкания, декораторы

Функция — объект, замыкание захватывает ячейку, а декоратор — просто присваивание. Три факта, из которых выводятся позднее связывание, потеря сигнатуры, тихая утечка через lru_cache и то, почему @property устроен так же, как @staticmethod.

L1 · 9 минL2 · 11 мин📱 телефонсверено · CPython 3.11, linux x86-64

1Предскажи

Зафиксируй вывод и причину до того, как откроешь ответ.

Фрагмент A
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]
Одно и то же выражение, разница в одном дефолтном аргументе.
Фрагмент B
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)
Имя, документация и сигнатура — всё чужое.
Фрагмент C
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
У функции есть поле, которого нет у обычной функции. Что в нём лежит?
Фрагмент D
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
Объект удалили, сборщик отработал, объект жив.

2Механизм

Три факта, каждый из которых по отдельности звучит безобидно. Вместе они объясняют всё содержимое этого урока.

Функция — объект. У неё есть атрибуты, её можно передать, положить в список, приписать ей поле. Инструкция def исполняется и создаёт этот объект — как уже было в уроке 01.

Замыкание захватывает ячейку, а не значение. Внутренняя функция, использующая имя из внешней, получает ссылку на общую ячейку; в момент вызова она читает то, что в ячейке лежит сейчас.

Декоратор — синтаксис для присваивания. @deco над def f означает буквально f = deco(f). Никакой отдельной сущности «декоратор» в языке нет.

Следствие 1 · позднее связывание

Фрагмент A. Все три лямбды ссылаются на одну ячейку переменной цикла. К моменту вызова цикл давно закончился, и в ячейке лежит 2. Ничего не «сломалось» — функции честно прочитали текущее значение, как и обещано определением.

Вариант с lambda i=i работает по другому механизму: значение по умолчанию вычисляется в момент создания функции (урок 01, следствие 3) и складывается в __defaults__. То есть починка происходит не через замыкание, а в обход него.

Фрагмент C показывает саму ячейку. co_freevars перечисляет захваченные имена, __closure__ хранит ячейки — у обычной функции этого поля попросту нет.

Следствие 2 · декоратор подменяет объект целиком

Раз @deco — это f = deco(f), наружу выходит другой объект. У него своё имя, своя документация, своя сигнатура. Исходная функция никуда не делась, но по имени target теперь доступна обёртка.

Фрагмент B — прямое следствие, а не недоработка. functools.wraps копирует метаданные с оригинала на обёртку и дополнительно кладёт ссылку в __wrapped__, по которой inspect.signature находит настоящую сигнатуру.

Отсюда правило без исключений: обёртка без wraps ломает всё, что читает метаданные функции — справку, отладчик, документацию, диспетчеризацию по типам и любой фреймворк, строящий поведение по аннотациям.

Следствие 3 · декоратор с аргументами — три уровня

@deco(x) раскрывается в f = deco(x)(f): сначала вызывается deco(x), и уже его результат применяется к функции. Значит уровней вложенности ровно три — принимающий настройки, принимающий функцию, принимающий вызов.

Это не соглашение, а арифметика раскрытия синтаксиса. И отсюда же понятно, почему @deco без скобок и @deco() со скобками — разные вещи, а библиотеки, поддерживающие обе формы, делают это ветвлением внутри.

Следствие 4 · где кэш держит объект

Фрагмент D. @lru_cache — обычный декоратор, применённый в теле класса, поэтому кэш живёт на функции, то есть на классе. Ключом служит кортеж аргументов, а первый аргумент метода — self. Значит кэш держит сильную ссылку на каждый экземпляр, для которого метод вызывался.

Из урока 09 известно, что объект жив, пока на него есть ссылка. Кэш её и держит — до вытеснения, а при maxsize=None вытеснения нет вообще. Тихая утечка на долгоживущем процессе, которую видно только по росту памяти.

И то, ради чего всё это связывается воедино

@property, @staticmethod, @classmethod — обычные декораторы. Они не встроены в синтаксис: это функции, возвращающие объект-дескриптор, который дальше работает по процедуре из урока 04. Три «магические» конструкции оказываются одним механизмом — декоратор плюс дескриптор — и запоминать их по отдельности не нужно.

3Границы модели

Где сказанное перестаёт держать

nonlocal и global — разные вещи. Первый привязывает имя к ячейке ближайшей внешней функции, второй — к модулю. Без них присваивание внутри функции создаёт новую локальную переменную, и чтение до присваивания даёт UnboundLocalError, а не значение снаружи.

Замыкание удерживает всё, на что смотрит. Ячейка держит объект, объект держит свой граф — механизм из урока 09 работает и здесь. Замыкание, случайно захватившее self или большой кадр данных, удержит его столько, сколько живёт функция.

wraps копирует не всё. Он переносит имя, документацию, модуль, аннотации и __dict__, но обёртка остаётся другим объектом: id другой, а inspect.signature честен только потому, что специально смотрит в __wrapped__. Код, сравнивающий функции по идентичности, всё равно сломается.

4Корень

Корень R2 в его самой прямой форме: функция — такой же объект, как всё остальное, и имя к ней просто привязано. Из этого механически следуют и декораторы, и замыкания, и то, что @property не нуждается в поддержке синтаксиса.

Порядок появления показателен. Замыкания и вложенные функции были почти с начала; синтаксис @ добавили только в 2.4 (PEP 318) — до этого писали f = deco(f) руками, и это работало точно так же. То есть декоратор появился как сокращение записи для уже существующего механизма, а не как новая возможность. Отсюда же nonlocal, добавленный лишь в Python 3: до него замыкание могло читать внешнюю переменную, но не присваивать ей, и обходились изменяемым списком из одного элемента.

5Аналогия

Замыкание в Python — это лямбда C++ с захватом по ссылке, и аналогия точная вплоть до последствий. Ячейка играет роль захваченной ссылки: обе смотрят на переменную, а не на её значение, обе видят изменения, сделанные после создания, обе дают «неожиданный» результат в цикле.

Расходятся они в одном месте, и это тоже поучительно. В C++ захват по ссылке может пережить переменную и превратиться в висячую ссылку — классический источник неопределённого поведения. В Python ячейка удерживает объект живым, потому что действует счётчик ссылок из урока 09. Тот же приём, но одна модель памяти делает его опасным, а другая — просто контринтуитивным.

6Вопросы пытливого ума

Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.

Почему замыкание захватывает переменную, а не значение?

Потому что захватывается ячейка, а не содержимое, — и это то же самое решение, что делает 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++ лямбды, Java, JavaScriptЯвный захват [=] и [&] против единственного варианта в Python

C++ — самое точное сравнение, потому что там выбор захвата явный. [=] копирует значение в момент создания лямбды, [&] захватывает по ссылке. Python всегда делает второе: захват по ячейке, то есть [&] без альтернативы. Отсюда и позднее связывание из следствия 1 — в C++ ровно то же поведение получается при [&], и ровно так же ловит начинающих, только там ещё и ссылка может пережить область видимости и стать висячей. Python от последнего защищён счётчиком ссылок: ячейка держит объект живым.

Приём lambda i=i: i — это ручная эмуляция [=]: скопировать значение в момент создания. Зная C++, конструкция перестаёт выглядеть трюком.

JavaScript прошёл через ту же боль и решил её на уровне языка: var вёл себя как Python, let в цикле создаёт новую привязку на каждой итерации. Python такого не сделал — и не сделает, из-за R9.

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

🐛 багОбёртка без wraps ломает фреймворкСигнатура превращается в (*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-схемы.

⚡ ценаДекоратор не бесплатен, но lru_cache окупает себяОбёртка удваивает стоимость вызова; кэш на рекурсии превращает 0.2 с в микросекунды

Две стороны одной медали: за декоратор платят на каждом вызове, но иногда он возвращает в тысячи раз больше.

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

2 000 000 вызововвремя
напрямую0.109 с
через обёртку0.257 с

В два с половиной раза — потому что вместо одного вызова происходят два, плюс распаковка *a, **k. На функции, делающей реальную работу, это теряется в шуме; на микрофункции в горячем цикле — нет. Практический вывод: не декорировать то, что вызывается миллионы раз и само по себе стоит наносекунды.

Когда обёртка окупается. Та же механика, но с кэшем, на рекурсивной функции:

fib(25), 20 прогоноввремя
без кэша0.202 с
с @lru_cache после прогрева0.000004 с

Обёртка стоит те же наносекунды, но заменяет экспоненциальное дерево вызовов одним поиском в словаре. Это не оптимизация функции, а смена класса сложности — как выбор структуры данных в уроке 03.

Два условия применимости, оба выводятся из механизма: аргументы должны быть хешируемыми (урок 05), и кэш нельзя вешать на методы — следствие 4 показывает, во что это обходится. Для методов есть cached_property для неизменяемых объектов или явный кэш в самом экземпляре.

💻 терминалПроверь в REPLЧетыре фрагмента плюс UnboundLocalError, порядок декораторов и ячейка после выхода

Прогони четыре фрагмента, сверяясь с предсказанием. Затем:

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.

исходникиfunctools и dataclassesКак выглядит wraps изнутри и зачем dataclass генерирует код строкой

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.

L2Ячейки, MAKE_FUNCTION и стоимость вызоваКак компилятор решает, что переменная свободная, и что лежит в __closure__

Из 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 кадры стали дешевле, поэтому на новых версиях разрыв меньше — проверять стоит на своей.

L3Где это в исходниках CPythonfuncobject.c, cellobject.c, symtable.c — три файла, закрывающие урок

Тег v3.11.15.

  • Objects/cellobject.c — весь тип ячейки, полтораста строк. Он устроен предельно просто: один указатель, и это буквально «ссылка на объект», о которой шла речь в уроке 01.
  • Python/symtable.c — классификация имён при компиляции: локальное, свободное, ячеечное, глобальное. Здесь принимается решение, порождающее UnboundLocalError.
  • Objects/funcobject.c, func_new_impl — из чего состоит объект функции: код, глобальные, дефолты, замыкание, аннотации.
  • PEP 318 — введение синтаксиса @ в 2.4; в мотивации прямо сказано, что механизм уже существовал и добавляется только запись.
связиКуда это ведётL01, L04, L07, L09 · контраст с явным захватом в C++ и let в JavaScript
корень R2 · Атрибут резолвится в рантайме, неймспейс — dict
← основа L01 · Имя, объект, ссылка — «функция это объект» и вычисление дефолтов при создании
← основа L04 · obj.x насквозь — property и staticmethod как дескрипторы
← основа L09 · Подсчёт ссылок — почему кэш на методе не даёт экземпляру умереть
↔ контраст Лямбды C++: [=] против [&], выбор есть — в Python его нет
↔ контраст let в JavaScript: та же проблема, решённая изменением языка
→ дальше L07 · Метаклассы — декоратор класса как самый лёгкий уровень вмешательства
чего здесь нет Генераторы и корутины — тоже объекты с кадром, но это отдельный протокол; R5
источникиЧто почитатьwraps и update_wrapper 🟧 · PEP 318 🟦 · dataclasses.py 🟥