Deep Engineering

ЗАМЕР

bench/introspection/closure_vars.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/python/closures-and-scope
Как запустить
Ни в одном из трёх нет измерения времени: смотрим на значения, типы и признаки.
Адреса объектов в выводе заменены на `0x...`, чтобы прогон был сравним между
запусками.

## `inspect.unwrap` и `__signature__`

Вывод **одинаков на всех четырёх версиях** (различается только строка `PY`):

Запись прогона

Замеры: интроспекция обёрток и замыканий

Три сюжета, которых нет в bench/decorators/ и bench/closures/: снятие обёртки и подмена видимой сигнатуры, поломка признака «корутинная функция» синхронной обёрткой, и разница между ячейкой замыкания и снимком значения.

скрипт что показывает статьи
unwrap_and_signature.py inspect.unwrap проходит цепочку __wrapped__ целиком; __signature__ перебивает и цепочку, и follow_wrapped=False декораторы
async_decorator.py синхронная обёртка над async def даёт iscoroutinefunction() == False; три способа починки декораторы
closure_vars.py inspect.getclosurevars; ячейка читается поздно, __defaults__ и partial — рано замыкания

Запуск:

for v in 3.11 3.12 3.13 3.14; do
  echo "== $v"; python$v bench/introspection/async_decorator.py
done

Ни в одном из трёх нет измерения времени: смотрим на значения, типы и признаки. Адреса объектов в выводе заменены на 0x..., чтобы прогон был сравним между запусками.

inspect.unwrap и __signature__

Вывод одинаков на всех четырёх версиях (различается только строка PY):

$ python3.13 unwrap_and_signature.py
PY 3.13.7

1) цепочка __wrapped__ сверху вниз: ['c', 'b', 'a', 'ОРИГИНАЛ']
   три обёртки над одной функцией — и все зовутся 'target': ['target', 'target', 'target']

2) inspect.unwrap(three) is target: True
   а three is target: False
   один __wrapped__ снимает только слой: False | его layer: b

3) unwrap(..., stop=layer=='a') остановился на слое: a

4) цикл в __wrapped__ -> ValueError: wrapper loop when unwrapping <function loop_a at 0x...>

5) видимые сигнатуры одной и той же обёртки:
   signature(three)                       (x: int, y: str = 's') -> bool
   signature(three, follow_wrapped=False) (*a, **kw) -> bool
   реальные параметры обёртки             ('a', 'kw')

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

7) __signature__ стоит на ВНУТРЕННЕЙ обёртке:
   signature(outer_over_inner) (count: int, *, label: str = '?') -> None
   (не (x: int, y: str = 's') -> bool — раскрутка встала на слое с __signature__)

8) что увидит генератор документации:
   str(inspect.signature(lying)) -> "(count: int, *, label: str = '?') -> None"
   вызов lying(1) при этом уйдёт в target и сработает: True
   bind(count=1, label='x') по ПОДДЕЛЬНОЙ сигнатуре прошёл
   а реальный вызов lying(count=1, label='x') -> TypeError: target() got an unexpected keyword argument 'count'

Три строки, ради которых написан файл.

Пункт 6: follow_wrapped=False — не способ добраться до правды. Он отключает проход по __wrapped__, но __signature__ стоит на самой обёртке, и поэтому побеждает в обоих случаях. Единственный, кто по-прежнему честен, — __code__ (пункт 5, ('a', 'kw')).

Пункт 7: __signature__ на любом слое цепочки останавливает раскрутку внутри signature, но не внутри unwrap. Это два разных обхода одной цепочки.

Пункт 8: расхождение между заявленной и настоящей сигнатурой не ловится ничем. Signature.bind проверяет по подделке и пропускает вызов, который упадёт.

Первоисточники, Doc/library/inspect.rst (тег v3.14.5):

  • строки 1289–1303, 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 возбуждается при обнаружении цикла.»

  • строки 803–808, impl-detail внутри описания 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__, мы можем использовать его для построения сигнатуры. Точная семантика — деталь реализации и может измениться без объявления. За текущей семантикой обращайтесь к исходному коду.»

    Формулировка проверена на всех четырёх версиях документации (теги v3.11.15, v3.12.3, v3.13.7, v3.14.5) — она дословно одна и та же. То есть «деталь реализации» здесь не означает «менялось»: не менялось.

Асинхронный декоратор

3.11.15 3.12.3 3.13.7 3.14.7
iscoroutinefunction(sync_wrapper) False False False False
inspect.markcoroutinefunction существует нет есть есть есть
asyncio.iscoroutinefunction даёт DeprecationWarning нет нет нет да
$ python3.11 async_decorator.py
PY 3.11.15

1) что скопировал wraps и что не скопировал:
   __name__         : job
   __doc__          : 'Настоящая корутинная функция.'
   __wrapped__ is job: True
   iscoroutinefunction(job)   : True
   iscoroutinefunction(broken): False

2) признак — флаг кода, а не атрибут:
   job.__code__.co_flags & CO_COROUTINE   : True
   broken.__code__.co_flags & CO_COROUTINE: False
   asyncio.iscoroutinefunction(broken): False

3) диспетчер, который смотрит на iscoroutinefunction:
   dispatch(job, 21)   -> 42
   dispatch(broken, 21)-> coroutine <coroutine object job at 0x...>
   вместо числа наружу уехал объект корутины: он не выполнен,
   и при сборке мусора даст RuntimeWarning 'was never awaited'

4) починка A — обёртка сама async def:
   iscoroutinefunction(fixed_a): True
   результат: 42

5) починка B — inspect.markcoroutinefunction:
   доступна: False — на этой версии markcoroutinefunction нет
   единственный переносимый способ — сделать обёртку async def

6) декоратор, выбирающий форму обёртки по цели:
   iscoroutinefunction(smart_deco(job))  : True
   iscoroutinefunction(smart_deco(plain)): False
   smart_deco(plain)(1) = 2 | asyncio.run(smart_deco(job)(21)) = 42

7) iscoroutinefunction по __wrapped__ НЕ идёт:
   iscoroutinefunction(broken)                : False
   iscoroutinefunction(inspect.unwrap(broken)): True
   то есть проверять надо оригинал, а чинить — обёртку

На 3.12.3 и 3.13.7 пункт 5 выглядит так:

5) починка B — inspect.markcoroutinefunction:
   доступна: True
   iscoroutinefunction(marked): True
   а флаг кода по-прежнему НЕ корутинный: False
   маркер — обычный атрибут: True
   asyncio.iscoroutinefunction(marked): True

На 3.14.7 к обеим строкам с asyncio.iscoroutinefunction добавляется:

   [DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for removal in Python 3.16; use inspect.iscoroutinefunction() instead]

Что тут важно. Пункт 2: признак — это флаг объекта кода CO_COROUTINE (0x0080), а не атрибут функции; functools.wraps копирует атрибуты и до флага не дотягивается по определению. Пункт 5: markcoroutinefunction чинит ответ iscoroutinefunction, не трогая флаг, — он ставит обычный атрибут _is_coroutine_marker. Пункт 7: iscoroutinefunction не идёт по __wrapped__, поэтому подсказка «оберни в wraps и всё наладится» не работает.

Первоисточник — Doc/library/inspect.rst, строки 438–472 (тег v3.14.5):

Return True if the object is a coroutine function (a function defined with an async def syntax), a functools.partial wrapping a coroutine function, or a sync function marked with markcoroutinefunction. […] .. versionchanged:: 3.12 — Sync functions marked with markcoroutinefunction now return True.

Перевод: «Возвращает True, если объект — корутинная функция (функция, определённая синтаксисом async def), functools.partial вокруг корутинной функции или синхронная функция, помеченная markcoroutinefunction. […] Изменено в версии 3.12: синхронные функции, помеченные markcoroutinefunction, теперь дают True

И там же, строки 460–473:

markcoroutinefunction(func) — 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. .. versionadded:: 3.12

Перевод: «Декоратор, помечающий вызываемый объект как корутинную функцию, если иначе iscoroutinefunction её не распознаёт. […] Когда возможно, предпочтительна функция async def. Добавлено в версии 3.12.»

Ячейка против снимка

Вывод closure_vars.py одинаков на всех четырёх версиях:

$ python3.13 closure_vars.py
PY 3.13.7

1) getclosurevars(by_closure):
   nonlocals: {'n': 2}
   globals  : {'GLOBAL_RATE': 10}
   builtins : {'len': <built-in function len>}
   unbound  : set()

2) getclosurevars(by_default) — n НЕ в nonlocals, он параметр:
   nonlocals: {}
   globals  : {'GLOBAL_RATE': 10}
   а значение снимка лежит здесь: (2,)

3) getclosurevars(partial) -> TypeError: functools.partial(<function make_all_three.<locals>.body at 0x...>, 2) is not a Python function
   значение снимка у partial: (2,) | func: body
   getclosurevars(partial.func): {}

4) unbound у функции с несуществующим именем: {'NOT_DEFINED_ANYWHERE'}

5) до переприсваивания: первое | первое | первое
   после rebind('второе'): второе | первое | первое
   замыкание видит новое значение, снимки — старое

6) хранилища:
   closure: __closure__ = (<cell at 0x...: str object at 0x...>,) -> cell_contents = ['второе']
   default: __defaults__ = ('первое',) | __closure__ = None
   partial: .args = ('первое',) | .keywords = {}

7) запись в cell_contents снаружи: третье
   запись в __defaults__ снаружи: четвёртое
   partial неизменяем — нужен новый объект: пятое

8) три способа в цикле:
   замыкание по i     : [2, 2, 2]
   аргумент по умолч. : [0, 1, 2]
   functools.partial  : [0, 1, 2]

Пункты 1–3 — про интроспекцию: getclosurevars показывает только ячейки, глобальные и встроенные имена. Снимок в аргументе по умолчанию для него не существует (пункт 2: nonlocals пуст), а functools.partial он вообще отказывается принимать — TypeError, потому что это не питоновская функция.

Пункты 5–8 — про поведение: три способа «запомнить значение» дают одинаковый результат в цикле (пункт 8) и разный при переприсваивании (пункт 5). Ровно та же граница видна и в хранилищах (пункт 6): __closure__ — кортеж ячеек, __defaults__ — кортеж значений, partial.args — кортеж значений.

Первоисточник — Doc/library/inspect.rst, строки 1273–1287 (тег v3.14.5):

Get the mapping of external name references in a Python function or method func to their current values. A named tuple ClosureVars(nonlocals, globals, builtins, unbound) is returned. nonlocals maps referenced names to lexical closure variables, globals to the function's module globals and builtins to the builtins visible from the function body. unbound is the set of names referenced in the function that could not be resolved at all given the current module globals and builtins. TypeError is raised if func is not a Python function or method.

Перевод: «Получить отображение внешних имён, на которые ссылается питоновская функция или метод func, в их текущие значения. Возвращается именованный кортеж ClosureVars(nonlocals, globals, builtins, unbound). nonlocals сопоставляет упомянутые имена переменным лексического замыкания, globals — глобальным именам модуля функции, builtins — встроенным именам, видимым из тела функции. unbound — множество имён, упомянутых в функции, которые вообще не удалось разрешить при текущих глобальных и встроенных именах модуля. TypeError возбуждается, если func не является питоновской функцией или методом.»

Слово «current values» в первом предложении — не оборот речи: пункт 7 меняет cell_contents снаружи, и следующий вызов getclosurevars показал бы уже новое значение. Снимок так не умеет.

Оговорка про версию

3.14 здесь — это 3.14.7. Единственное, что взято именно с неё, — текст DeprecationWarning у asyncio.iscoroutinefunction. Все остальные строки на 3.14 совпадают с 3.13.7.

Тег исходников CPython для цитат из Doc/v3.14.5; формулировка про __signature__ дополнительно сверена с тегами v3.11.15, v3.12.3 и v3.13.7.

Скрипт

129 строк
"""inspect.getclosurevars и снимок значения: аргумент по умолчанию против functools.partial.

Один сюжет: у «замыкания» и у «снимка» разные механизмы хранения, и они
по-разному видны интроспекции. Замыкание держит ЯЧЕЙКУ (значение читается в
момент вызова), аргумент по умолчанию и partial держат ЗНАЧЕНИЕ (прочитано в
момент создания). getclosurevars показывает только первое.

Запускать: python3.11 / 3.12 / 3.13 / 3.14.
"""
import functools
import inspect
import re
import sys


def noaddr(x):
    """Адреса объектов заменены, чтобы вывод был воспроизводим между запусками."""
    return re.sub(r"0x[0-9a-f]+", "0x...", str(x))

print("PY", sys.version.split()[0])
print()

GLOBAL_RATE = 10


def make_all_three(n):
    """Три способа «запомнить» n."""
    def by_closure():
        return n * GLOBAL_RATE + len("x")

    def by_default(n=n):
        return n * GLOBAL_RATE + len("x")

    def body(n):
        return n * GLOBAL_RATE + len("x")

    by_partial = functools.partial(body, n)
    return by_closure, by_default, by_partial, body


closure_fn, default_fn, partial_fn, body_fn = make_all_three(2)

# --- 1. Что показывает getclosurevars --------------------------------------
cv = inspect.getclosurevars(closure_fn)
print("1) getclosurevars(by_closure):")
print("   nonlocals:", cv.nonlocals)
print("   globals  :", cv.globals)
print("   builtins :", cv.builtins)
print("   unbound  :", cv.unbound)
print()

cv2 = inspect.getclosurevars(default_fn)
print("2) getclosurevars(by_default) — n НЕ в nonlocals, он параметр:")
print("   nonlocals:", cv2.nonlocals)
print("   globals  :", cv2.globals)
print("   а значение снимка лежит здесь:", default_fn.__defaults__)
print()

# --- 3. partial getclosurevars не принимает --------------------------------
try:
    inspect.getclosurevars(partial_fn)
    print("3) getclosurevars(partial) — принял")
except TypeError as e:
    print("3) getclosurevars(partial) ->", type(e).__name__ + ":", noaddr(e))
print("   значение снимка у partial:", partial_fn.args, "| func:", partial_fn.func.__name__)
print("   getclosurevars(partial.func):", inspect.getclosurevars(partial_fn.func).nonlocals)
print()

# --- 4. unbound: имя, которое не нашлось -----------------------------------
def uses_missing():
    return NOT_DEFINED_ANYWHERE + 1   # noqa: F821


print("4) unbound у функции с несуществующим именем:",
      inspect.getclosurevars(uses_missing).unbound)
print()

# --- 5. Главная разница: ячейка читается ПОЗДНО, снимок — РАНО -------------
def make_reader():
    value = "первое"

    def by_closure():
        return value

    def by_default(v=value):
        return v

    by_partial = functools.partial(lambda v: v, value)

    def rebind(new):
        nonlocal value
        value = new

    return by_closure, by_default, by_partial, rebind


c, d, p, rebind = make_reader()
print("5) до переприсваивания:", c(), "|", d(), "|", p())
rebind("второе")
print("   после rebind('второе'):", c(), "|", d(), "|", p())
print("   замыкание видит новое значение, снимки — старое")
print()

# --- 6. Где физически лежит каждое из трёх ---------------------------------
print("6) хранилища:")
print("   closure: __closure__ =", noaddr(c.__closure__),
      "-> cell_contents =", [cell.cell_contents for cell in c.__closure__])
print("   default: __defaults__ =", d.__defaults__, "| __closure__ =", d.__closure__)
print("   partial: .args =", p.args, "| .keywords =", p.keywords)
print()

# --- 7. Ячейку можно переписать снаружи, снимок — тоже, но иначе -----------
c.__closure__[0].cell_contents = "третье"
print("7) запись в cell_contents снаружи:", c())
d.__defaults__ = ("четвёртое",)
print("   запись в __defaults__ снаружи:", d())
p2 = functools.partial(p.func, "пятое")
print("   partial неизменяем — нужен новый объект:", p2())
print()

# --- 8. Классическая ловушка цикла и её починка снимком ---------------------
late = [lambda: i for i in range(3)]
early = [lambda i=i: i for i in range(3)]
parts = [functools.partial(lambda i: i, i) for i in range(3)]
print("8) три способа в цикле:")
print("   замыкание по i     :", [f() for f in late])
print("   аргумент по умолч. :", [f() for f in early])
print("   functools.partial  :", [f() for f in parts])