Python Функции, области видимости, замыкания, декораторы
0%

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

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

Синтаксис def вы уже знаете — если нет, начните с курса «Программирование с нуля», там функции разобраны с азов. Эта глава про другое: что именно создаёт интерпретатор, когда исполняет def, где физически живут имена, почему UnboundLocalError возникает на строке до присваивания и почему половина магии популярных фреймворков — это три вложенные функции и functools.wraps.

Модель исполнения здесь важнее синтаксиса. Разобравшись в ней один раз, вы перестанете гадать: почему список по умолчанию «помнит» прошлые вызовы, почему все лямбды из цикла вернули одно и то же, почему @lru_cache на методе течёт памятью, а @retry без скобок молча возвращает функцию вместо результата. Это продолжение разговора о модели данных: функция — такой же объект, как список или словарь, просто у неё есть __call__.

Функция — объект, а не подпрограмма

def — это исполняемая инструкция, а не декларация. Когда интерпретатор доходит до неё, он берёт заранее скомпилированный объект кода, приклеивает к нему текущие глобали, значения по умолчанию и ячейки замыкания — и связывает результат с именем.

def greet(name: str, greeting: str = "привет") -> str:
    """Возвращает приветствие."""
    return f"{greeting}, {name}!"

print(type(greet))                 # <class 'function'>
print(greet.__name__)              # greet
print(greet.__qualname__)          # greet
print(greet.__defaults__)          # ('привет',)
print(greet.__annotations__)       # {'name': <class 'str'>, ...}
print(greet.__code__.co_varnames)  # ('name', 'greeting')

greet.calls = 0                    # у функции есть свой __dict__ (PEP 232)
alias = greet                      # можно присвоить, положить в список, передать

Практическое следствие: функцию можно хранить в словаре (диспетчер команд вместо цепочки if), передавать в sorted(key=...), возвращать из другой функции, навешивать на неё атрибуты (так работают pytest.mark, метки Celery, регистрация в Click). Всё это — не «функциональное программирование ради красоты», а обычный способ убрать ветвление из кода.

Анатомия: function, code, cell, frame

Внутри CPython за вызов отвечают четыре разных объекта, и их полезно различать.

  • Code object — байткод плюс метаданные, неизменяемый, создаётся при компиляции модуля. Два раза исполненный def в цикле даст два разных function, но один и тот же __code__.
  • Function object — «code + окружение»: глобали, дефолты, ячейки, атрибуты. Именно он живёт в переменной.
  • Cell — коробочка в куче для переменной, захваченной вложенной функцией.
  • Frame — кадр вызова: массив локальных слотов, стек значений, ссылка на вызывающий кадр. Именно кадры вы видите в traceback.

Разделение объясняет цену вызова: создать function дорого-ишь (аллокация), создать frame — дёшево (CPython 3.11+ размещает кадры в собственном чанк-стеке и переиспользует память). Поэтому определять функцию внутри горячего цикла — плохая идея, а вызывать её там — нормальная.

Сигнатура как контракт вызова

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

def export(
    dataset,            # позиционный или именованный
    path,               # позиционный или именованный
    /,                  # <- всё, что левее: ТОЛЬКО позиционно (PEP 570)
    fmt="csv",          # позиционный или именованный
    *,                  # <- всё, что правее: ТОЛЬКО именованно (PEP 3102)
    sep=",",
    compress=False,
    **extra,            # произвольные именованные
):
    ...

export(df, "out.csv", "parquet", sep=";", compress=True)   # ок
export(dataset=df, path="out.csv")                          # TypeError? нет!

Последняя строка — важный нюанс. Раз dataset и path позиционные-только, их имена свободны и уходят в **extra. Тот же приём использует стандартная библиотека: dict.update(self, **kwargs) умеет принять ключ self, потому что self объявлен позиционным-только.

Правила, которые стоит применять по умолчанию:

  • Булевы флаги и опции — всегда keyword-only (* перед ними). export(df, "a.csv", True, False) нечитаем и ломается при добавлении параметра; compress=True — самодокументирован.
  • Позиционные-только (/) — для параметров, чьё имя не должно становиться частью публичного API (обёртки, self-подобные, аргументы, которые вы хотите переименовать без слома совместимости).
  • *args/**kwargs — только когда вы честно пробрасываете аргументы дальше (декораторы, адаптеры). Использовать их «на всякий случай» в бизнес-логике значит выключить и проверки интерпретатора, и статический анализ, и автодополнение.

Прочитать сигнатуру программно — задача inspect. На этом построены FastAPI, pytest, Click, dataclasses:

import inspect

def endpoint(user_id: int, *, verbose: bool = False) -> dict: ...

sig = inspect.signature(endpoint)
print(sig)                        # (user_id: int, *, verbose: bool = False) -> dict
print(sig.parameters["verbose"].kind)   # KEYWORD_ONLY

bound = sig.bind(42, verbose=True)
bound.apply_defaults()
print(bound.arguments)            # {'user_id': 42, 'verbose': True}

sig.bind() валидирует вызов до вызова — так фреймворки заранее понимают, какие параметры взять из query string, какие из тела запроса, а какие внедрить из DI-контейнера.

Грабля №1: изменяемое значение по умолчанию

Значения по умолчанию вычисляются один раз, в момент исполнения def, и хранятся в __defaults__. Не при каждом вызове.

def add_item(item, basket=[]):     # НИКОГДА так не делайте
    basket.append(item)
    return basket

print(add_item("хлеб"))            # ['хлеб']
print(add_item("молоко"))          # ['хлеб', 'молоко']   ← общее состояние!
print(add_item.__defaults__)       # (['хлеб', 'молоко'],)

Список — один на все вызовы, потому что он лежит в объекте функции. То же самое случается с dict, set, объектом-логгером и особенно с datetime.now(): def log(msg, ts=datetime.now()) навсегда зафиксирует время импорта модуля.

Канонический фикс — sentinel:

def add_item(item: str, basket: list[str] | None = None) -> list[str]:
    if basket is None:
        basket = []
    basket.append(item)
    return basket

Если None — валидное значение параметра (например, «явно передали null»), заводят собственный маркер:

_MISSING = object()   # уникальный объект, сравнивается по is

def get(mapping, key, default=_MISSING):
    try:
        return mapping[key]
    except KeyError:
        if default is _MISSING:
            raise            # дефолта не передавали — пробрасываем
        return default       # передали None — вернём None

Линтеры ловят это автоматически: правило B006 в flake8-bugbear и ruff (см. настройку инструментов). Включите ruff — и грабля перестанет существовать.

Области видимости: LEGB — не поиск, а решение компилятора

Классическая мнемоника LEGB (Local → Enclosing → Global → Built-in) описывает результат, но создаёт неверную интуицию, будто интерпретатор на каждом обращении бегает по цепочке словарей. Это не так.

Компилятор статически, ещё до запуска, решает для каждого имени, каким опкодом его доставать. Правило простое: если в теле функции есть хоть одно присваивание имени (включая for x in, with ... as x, import x, def x, class x), имя считается локальным во всём теле, с первой строки.

Где живут имена в CPython и какой опкод их достаёт

Отсюда самая частая ошибка новичков в чужом коде:

counter = 0

def tick():
    print(counter)   # UnboundLocalError, хотя глобальная counter существует
    counter += 1

tick()
# UnboundLocalError: cannot access local variable 'counter'
#                    where it is not associated with a value

Присваивание counter += 1 в последней строке сделало counter локальным во всём теле, и print обращается к пустому слоту. Сообщение об ошибке в Python 3.11+ стало внятным — раньше было загадочное «local variable referenced before assignment».

global и nonlocal

Эти инструкции не создают область видимости — они меняют решение компилятора об опкоде.

counter = 0

def tick():
    global counter       # counter → STORE_GLOBAL
    counter += 1

def make_tick():
    n = 0
    def tick():
        nonlocal n       # n → STORE_DEREF в ячейку make_tick
        n += 1
        return n
    return tick

global в продакшн-коде — почти всегда запах. Мутируемое глобальное состояние ломает тесты (порядок выполнения начинает влиять на результат), мешает многопоточности и делает функцию непереиспользуемой. Исключения: ленивая инициализация синглтона на уровне модуля и счётчики в скриптах. Всё остальное — параметр, атрибут объекта или явный контейнер конфигурации (см. продакшн-архитектуру).

nonlocal не умеет дотягиваться до глобалей: если ближайшая внешняя функция не содержит такого имени, будет SyntaxError на этапе компиляции.

Тело класса — особая область

Тело class исполняется в собственном пространстве имён, но оно не участвует в замыканиях: вложенные функции и comprehension-ы его не видят.

class Config:
    limits = [1, 2, 3]
    doubled = [x * 2 for x in limits]        # работает: limits — внешний итерируемый
    scaled = [x * limits[0] for x in range(3)]   # NameError: name 'limits' is not defined

    def show(self):
        print(limits)                        # NameError: используйте self.limits

Причина: самый внешний итерируемый объект comprehension вычисляется в объемлющей области до входа в comprehension, а всё остальное — уже внутри, где имени limits нет. Внутри методов к атрибутам класса обращаются через self. или имя класса. Подробнее — в главе про ООП.

Comprehension-ы, генераторы и локальные имена

Переменная цикла comprehension не утекает наружу (в отличие от обычного for):

squares = [i * i for i in range(5)]
print(i)        # NameError

for j in range(5):
    pass
print(j)        # 4 — обычный for НЕ создаёт области, имя остаётся

В Python 3.12 (PEP 709) list/dict/set-comprehension-ы инлайнятся в кадр объемлющей функции: отдельный кадр больше не создаётся, ускорение до двух раз, а изоляция переменной цикла сохранена искусственно. Генераторные выражения по-прежнему компилируются в отдельную функцию — им нужен собственный приостанавливаемый кадр.

Ещё два места, где имена ведут себя неочевидно:

try:
    int("nope")
except ValueError as exc:
    pass
print(exc)      # NameError: имя удаляется в конце блока except!

def f():
    x = 1
    del x
    return x    # UnboundLocalError: слот существует, но пуст

Удаление exc — намеренное решение: исключение держит traceback, traceback держит кадры, кадры держат все локальные переменные. Без явного del любой except ... as e в функции создавал бы цикл ссылок. Если значение нужно после блока — скопируйте его в другую переменную.

locals() перестал врать

До Python 3.13 locals() внутри функции возвращал снимок, а запись в него молча терялась. PEP 667 навёл порядок: locals() в функции — независимый снимок (запись безопасна и просто ни на что не влияет), а frame.f_locals — write-through proxy, через который отладчики реально меняют переменные. Не используйте locals() для «магической» передачи аргументов (f(**locals())) — это нечитаемо и ломается при рефакторинге.

Байткод: почему локальные быстрее

import dis

count = 0

def make_counter(start):
    n = start
    def inc():
        nonlocal n
        n += count
        return n
    return inc

dis.dis(make_counter(0))    # дизассемблируем внутреннюю inc

Суть вывода (номера строк и точный набор опкодов зависят от версии):

COPY_FREE_VARS   1        # подтянуть ячейки из __closure__
RESUME           0
LOAD_DEREF       n        # чтение ячейки — по индексу, без хеширования
LOAD_GLOBAL      count    # словарь модуля → при промахе builtins
BINARY_OP       13 (+=)
STORE_DEREF      n
LOAD_DEREF       n
RETURN_VALUE

Три разных опкода — три разные цены:

Опкод Что делает Порядок стоимости
LOAD_FAST чтение слота массива по индексу ~1 нс, самый быстрый
LOAD_DEREF разыменование ячейки чуть дороже LOAD_FAST
LOAD_GLOBAL хеш-поиск в словаре модуля, потом в builtins заметно дороже; в 3.11+ смягчён инлайн-кешем

Отсюда старый приём: вынести часто используемый глобальный объект или метод в локальную переменную.

def normalize(rows: list[str]) -> list[str]:
    out = []
    append = out.append          # один LOAD_METHOD вместо N
    for row in rows:
        append(row.strip().lower())
    return out

Честная оговорка: с адаптивным специализирующим интерпретатором (PEP 659, Python 3.11+) выигрыш от таких трюков сильно уменьшился и часто теряется в шуме. Сначала профилируйте — см. производительность. Но понимать, почему глобали медленнее, полезно: это же объясняет, почему код на верхнем уровне модуля работает медленнее, чем тот же код внутри функции.

Замыкания: ячейки, а не копии

Когда вложенная функция обращается к переменной внешней, компилятор переводит эту переменную из слота кадра в ячейку (cell) в куче. Ячейка переживает кадр — поэтому замыкание работает и после возврата из внешней функции.

def make_counter(start: int = 0):
    n = start
    def inc() -> int:
        nonlocal n
        n += 1
        return n
    def get() -> int:
        return n
    return inc, get

inc, get = make_counter(10)
print(inc(), inc(), get())                       # 11 12 12
print(inc.__closure__)                           # (<cell at 0x...: int object at 0x...>,)
print(inc.__closure__[0].cell_contents)          # 12
print(inc.__closure__[0] is get.__closure__[0])  # True — ячейка ОДНА на обе функции
print(inc.__code__.co_freevars)                  # ('n',)

Два вывода, которые надо запомнить:

  1. Замыкание захватывает переменную, а не значение. Все функции, созданные в одном вызове, делят общие ячейки.
  2. Замыкание удерживает объект от сборки мусора. Если вы захватили в лямбду огромный DataFrame «только чтобы взять из него одно число» — этот DataFrame будет жить, пока жива лямбда. Классический источник утечек в колбэках и обработчиках событий: захватывайте минимум, вытаскивайте нужное значение до создания замыкания.

Грабля №2: позднее связывание в цикле

fns = [lambda: i for i in range(3)]
print([f() for f in fns])     # [2, 2, 2]  — а не [0, 1, 2]

Все три лямбды ссылаются на одну ячейку i, и к моменту вызова там лежит последнее значение. Проблема не в comprehension — точно так же ведёт себя обычный цикл, любые колбэки, обработчики кнопок в GUI и asyncio.create_task в цикле.

Три рабочих решения:

# 1. Захват значения через параметр по умолчанию (вычисляется на def)
fns = [lambda i=i: i for i in range(3)]

# 2. Фабрика: каждый вызов создаёт СВОЙ кадр и свою ячейку
def make(i):
    return lambda: i
fns = [make(i) for i in range(3)]

# 3. functools.partial — связывает аргумент здесь и сейчас
from functools import partial
fns = [partial(lambda i: i, i) for i in range(3)]

print([f() for f in fns])     # [0, 1, 2]

Вариант 1 короче, но засоряет сигнатуру (кто-то сможет вызвать f(99)). В библиотечном коде предпочтительнее 2 или 3.

Замыкание против класса

Замыкание — это объект с одним методом; класс — объект с несколькими. Выбирайте по числу операций над состоянием:

# Замыкание: одна операция, состояние скрыто намертво
def make_rate_limiter(limit: int, window: float):
    hits: list[float] = []
    def allow() -> bool:
        now = time.monotonic()
        hits[:] = [t for t in hits if now - t < window]
        if len(hits) >= limit:
            return False
        hits.append(now)
        return True
    return allow

# Класс: нужны reset(), stats(), сериализация, наследование
class RateLimiter:
    def __init__(self, limit: int, window: float) -> None: ...
    def allow(self) -> bool: ...
    def reset(self) -> None: ...

Замыкание даёт настоящую инкапсуляцию (снаружи до hits не добраться без __closure__), но плохо отлаживается, не пиклится и не поддаётся repr. Класс интроспектируем и расширяем. Правило большого пальца: одна операция — замыкание, две и больше — класс.

Функции высшего порядка на практике

from functools import partial, reduce
from operator import attrgetter, itemgetter, methodcaller
import json

# partial: зафиксировать часть аргументов, получив новый вызываемый объект
dump = partial(json.dumps, ensure_ascii=False, separators=(",", ":"))
print(dump({"имя": "Аня"}))        # {"имя":"Аня"}

# operator вместо лямбд в key= — быстрее и читаемее
users.sort(key=attrgetter("last_name", "first_name"))
rows.sort(key=itemgetter(2), reverse=True)
names = list(map(methodcaller("strip"), raw_lines))

partial предпочтительнее лямбды там, где объект переживёт вызов: он интроспектируем (p.func, p.args, p.keywords), пиклится и не подвержен позднему связыванию. Минус — у partial нет __name__, поэтому в реестрах плагинов оборачивайте его или храните имя отдельно.

reduce в Python сознательно вынесен из builtins: почти всегда есть более ясная альтернатива — sum, math.prod, itertools.accumulate, str.join, обычный цикл. Оставьте reduce для действительно нестандартной свёртки, и то с комментарием.

Про lambda PEP 8 высказывается прямо: не присваивайте лямбду имени (f = lambda x: x * 2) — пишите def. Вы бесплатно получите нормальный __name__ в трассировках и место для докстринга. Лямбда уместна ровно там, где выражение одноразовое и стоит внутри вызова: key=, default_factory=, Callable-параметр.

Декораторы: синтаксический сахар над одним присваиванием

@timed
def hello(name): ...

# ровно то же самое:
def hello(name): ...
hello = timed(hello)

Всё. Никакой магии — декоратор это функция, принимающая объект и возвращающая объект (не обязательно функцию: @dataclass возвращает класс, @property — дескриптор). Формально механизм описан в PEP 318.

Два момента, которые путают чаще всего: когда это происходит и в каком порядке.

Декоратор: что происходит при импорте, а что при вызове

  • Декораторы применяются в момент исполнения def, то есть при импорте модуля. Регистрация роутов в FastAPI/Flask, задач в Celery, команд в Click — всё это побочные эффекты импорта. Не импортировали модуль — эндпоинта не существует, и никакой ошибки вы не увидите.
  • Стек декораторов применяется снизу вверх, а исполняется сверху вниз. Ближайший к def становится самым внутренним слоем.

functools.wraps — не косметика

def naive(fn):
    def wrapper(*args, **kwargs):
        return fn(*args, **kwargs)
    return wrapper

@naive
def area(radius: float) -> float:
    """Площадь круга."""
    return 3.14159 * radius ** 2

import inspect
print(area.__name__)             # wrapper      ← имя потеряно
print(area.__doc__)              # None         ← докстринг потерян
print(inspect.signature(area))   # (*args, **kwargs)  ← сигнатура потеряна

Что ломается на практике: логи и метрики с именем функции, pickle (не найдёт wrapper по имени), Sphinx и автодокументация, pytest (фикстуры ищутся по имени), любые фреймворки, читающие аннотации — FastAPI перестанет валидировать тело запроса, потому что увидит *args, **kwargs.

import functools

def timed(fn):
    @functools.wraps(fn)          # копирует __name__, __qualname__, __doc__,
    def wrapper(*args, **kwargs): # __module__, __dict__ и ставит __wrapped__
        t0 = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:                  # finally: медленные падения тоже интересны
            print(f"{fn.__qualname__}: {(time.perf_counter() - t0) * 1000:.2f} мс")
    return wrapper

functools.wraps дополнительно записывает wrapper.__wrapped__ = fn, а inspect.signature по умолчанию идёт по этой цепочке — поэтому сигнатура снова выглядит правильно. Если вам нужна настоящая сигнатура обёртки, зовите inspect.signature(f, follow_wrapped=False). Всегда ставьте @functools.wraps. Всегда — это не преувеличение.

Декоратор с параметрами: три уровня вложенности

import random, time
from collections.abc import Callable

def retry(
    *,                                  # все параметры keyword-only — см. граблю ниже
    attempts: int = 3,
    delay: float = 0.1,
    exceptions: tuple[type[BaseException], ...] = (OSError,),
):
    """Фабрика декораторов: retry(...) → decorator → wrapper."""
    def decorator(fn):
        @functools.wraps(fn)
        def wrapper(*args, **kwargs):
            last: BaseException | None = None
            for attempt in range(1, attempts + 1):
                try:
                    return fn(*args, **kwargs)
                except exceptions as exc:
                    last = exc
                    if attempt == attempts:
                        break
                    # экспоненциальный backoff с джиттером против «стада»
                    time.sleep(delay * 2 ** (attempt - 1) * (0.5 + random.random()))
            assert last is not None
            raise last
        return wrapper
    return decorator

@retry(attempts=5, exceptions=(TimeoutError, ConnectionError))
def fetch(url: str) -> bytes: ...

Читается изнутри наружу: retry(...) вызывается сразу и возвращает decorator; decorator(fetch) возвращает wrapper; имя fetch указывает на wrapper. Retry без ограничения по типам исключений и без джиттера — антипаттерн: он превращает единичный сбой в лавину повторов. Про иерархию исключений и корректное перевыбрасывание — глава об ошибках.

Грабля №3: забытые скобки

def retry(attempts=3):          # позиционный параметр — опасная сигнатура
    def decorator(fn): ...
    return decorator

@retry                          # ЗАБЫЛИ скобки
def fetch(url): ...

result = fetch("https://api")   # не падает! возвращает функцию, а не байты

Разберём: retry(fetch) записал функцию в attempts и вернул decorator. Теперь fetch — это decorator, и вызов fetch("https://api") просто оборачивает строку, тихо возвращая wrapper. Ошибка вылезет через три слоя, в чужом коде, в виде TypeError: 'function' object is not subscriptable.

Профилактика — сделать все параметры фабрики keyword-only (def retry(*, attempts=3)). Тогда @retry немедленно падает с внятным TypeError. Либо явно поддержать оба варианта:

def timed(fn=None, *, label: str | None = None):
    if fn is None:                                  # вызвали как @timed(label=...)
        return functools.partial(timed, label=label)
    tag = label or fn.__qualname__
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        ...
    return wrapper

@timed                      # работает
def a(): ...

@timed(label="импорт CSV")  # тоже работает
def b(): ...

Декоратор-классом и грабля с методами

class CountCalls:
    def __init__(self, fn):
        functools.update_wrapper(self, fn)   # аналог wraps для не-функций
        self.fn = fn
        self.count = 0

    def __call__(self, *args, **kwargs):
        self.count += 1
        return self.fn(*args, **kwargs)

    def __get__(self, obj, objtype=None):
        # БЕЗ этого метода декоратор сломается на методах класса:
        # экземпляр CountCalls не дескриптор, self не будет подставлен
        if obj is None:
            return self
        return functools.partial(self.__call__, obj)

Причина в том, что обычные функции — дескрипторы: obj.method возвращает связанный метод именно через function.__get__. Экземпляр вашего класса этим свойством не обладает, поэтому либо реализуйте __get__, либо пишите декораторы функциями (проще), либо возьмите библиотеку wrapt, которая решает всё семейство таких проблем — прокси-объект там прозрачен и для методов, и для classmethod, и для интроспекции. Обязательное чтение по теме — серия Грэма Дамплтона «How you implemented your Python decorator is wrong». Дескрипторы подробно разбираются в главе про ООП.

Декораторы и async

Обёртка над корутинной функцией обязана сама быть корутинной, иначе вы вернёте невыполненный объект-корутину и получите RuntimeWarning: coroutine was never awaited.

import inspect

def timed(fn):
    if inspect.iscoroutinefunction(fn):
        @functools.wraps(fn)
        async def awrapper(*args, **kwargs):
            t0 = time.perf_counter()
            try:
                return await fn(*args, **kwargs)
            finally:
                log_duration(fn, time.perf_counter() - t0)
        return awrapper

    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        t0 = time.perf_counter()
        try:
            return fn(*args, **kwargs)
        finally:
            log_duration(fn, time.perf_counter() - t0)
    return wrapper

Отдельная тонкость: time.sleep внутри async-обёртки заблокирует весь event loop — нужен await asyncio.sleep(...). Поэтому «универсальный» @retry для sync и async почти всегда приходится писать двумя ветками. Детали — в главе о конкурентности.

Готовые декораторы из стандартной библиотеки

from functools import cache, lru_cache, singledispatch, cached_property
from contextlib import contextmanager

@cache                                   # то же, что lru_cache(maxsize=None)
def fib(n: int) -> int:
    return n if n < 2 else fib(n - 1) + fib(n - 2)

print(fib(100))          # 354224848179261915075
print(fib.cache_info())  # CacheInfo(hits=98, misses=101, maxsize=None, currsize=101)
fib.cache_clear()

@singledispatch                          # диспетчеризация по типу первого аргумента
def to_json(value):
    raise TypeError(f"не умею сериализовать {type(value).__name__}")

@to_json.register
def _(value: datetime) -> str:
    return value.isoformat()

@contextmanager
def span(name: str):
    t0 = time.perf_counter()
    try:
        yield
    finally:
        print(f"{name}: {(time.perf_counter() - t0) * 1000:.1f} мс")

with span("запрос"):        # работает как контекстный менеджер
    do_work()

@span("хендлер")            # ...и как декоратор — через ContextDecorator
def handler(): ...

@singledispatch — честная замена цепочке isinstance, особенно для сериализации и рендеринга. @contextmanager разбирается подробно в идиоматичном Python.

Грабли кеширующих декораторов — их стоит выучить наизусть:

  • @cache на методе держит сильную ссылку на self в ключе: экземпляры никогда не соберутся сборщиком мусора. Это настоящая утечка памяти в долгоживущем сервисе. Решения: кеш на уровне модуля с явными аргументами, cached_property, кеш в самом экземпляре, weakref.
  • Аргументы должны быть хешируемыми: список или dict дадут TypeError: unhashable type.
  • Ключ зависит от формы вызова: f(1, 2) и f(1, b=2) — два разных ключа, два разных вычисления.
  • Кеш никогда не инвалидируется сам. Для данных из БД это тихо устаревшие ответы; нужен TTL — берите внешнюю библиотеку (cachetools) или Redis.
  • @cached_property с Python 3.12 больше не берёт блокировку: под нагрузкой значение может вычислиться несколько раз. Если вычисление дорогое или имеет побочные эффекты — синхронизируйте сами.

Экосистема: где вы встретите декораторы

Декораторы — идеальный инструмент для сквозной функциональности (cross-cutting concerns): того, что нужно всем функциям и не является их сутью. Как только декоратор начинает менять смысл функции — подставлять аргументы, переписывать результат, глотать исключения — читаемость падает катастрофически: код в теле функции больше не описывает её поведение.

Типизация декораторов

Наивно типизированный декоратор Callable[..., Any] стирает всю информацию о сигнатуре, и mypy перестаёт проверять вызовы декорированной функции. Начиная с PEP 612 есть ParamSpec:

from collections.abc import Callable
from typing import ParamSpec, TypeVar, Concatenate

P = ParamSpec("P")
R = TypeVar("R")

def timed(fn: Callable[P, R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        return fn(*args, **kwargs)
    return wrapper

# Декоратор, который САМ подставляет первый аргумент:
def with_conn(fn: Callable[Concatenate[Connection, P], R]) -> Callable[P, R]:
    @functools.wraps(fn)
    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
        with open_connection() as conn:
            return fn(conn, *args, **kwargs)
    return wrapper

С Python 3.12 (PEP 695) синтаксис короче и не требует глобальных P/R:

def timed[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
    ...

Типизировать декоратор с параметрами (@retry(attempts=3)) сложнее: фабрика возвращает Callable[[Callable[P, R]], Callable[P, R]]. Декоратор, меняющий сигнатуру (например, добавляющий параметр), система типов выразит только частично — это честное ограничение. Подробности — в главе про аннотации типов.

Цена вызова и рекурсия

Вызов функции в CPython стоит десятки наносекунд — на порядки дороже, чем в Go или C, где компилятор умеет инлайнить. Практические следствия:

import timeit

setup = "xs = list(range(1000))"
print(timeit.timeit("[x * 2 for x in xs]", setup, number=10_000))
print(timeit.timeit("list(map(lambda x: x * 2, xs))", setup, number=10_000))
# comprehension обычно быстрее: в нём нет вызова на каждый элемент

map с готовой C-функцией (map(str.strip, lines)) выигрывает; map с лямбдой почти всегда проигрывает comprehension-у, потому что добавляет реальный вызов на каждую итерацию.

Рекурсия в Python — инструмент выразительности, а не производительности:

import sys
print(sys.getrecursionlimit())   # 1000 по умолчанию

Оптимизации хвостовых вызовов нет и не будет — Гвидо объяснил позицию в заметке Tail Recursion Elimination: TCO ломает трассировки, а внятные трассировки для Python важнее. Поэтому обход дерева глубиной в миллион узлов пишут итеративно с явным стеком, а не рекурсивно. Поднимать sys.setrecursionlimit() — временная заплатка: в старых версиях это приводило к сегфолту при переполнении C-стека; в 3.12+ чисто питоновские вызовы больше не расходуют C-стек, но лимит всё равно защищает от бесконечной рекурсии.

Честно: где эта модель выигрывает, а где мешает

Выигрывает. Функции первого класса плюс декораторы дают почти бесплатное метапрограммирование: FastAPI умеет выводить схему запроса из аннотаций, pytest — собирать фикстуры по именам параметров, dataclass — генерировать __init__. В Java или C# то же самое требует аннотаций плюс рефлексии плюс генерации байткода. Интроспекция (inspect) делает возможными инструменты, которых просто нет в статических языках.

Мешает. Та же динамика — источник боли:

  • Нет перегрузки по сигнатуре. @singledispatch и typing.overload — частичные протезы; последний вообще не влияет на рантайм.
  • Декораторы почти невидимы для инструментов. Стек из четырёх обёрток удлиняет traceback, путает отладчик и профилировщик, ломает автодополнение, если вы забыли ParamSpec.
  • Всё изменяемо. Никакой const, никаких приватных полей: любой код может переписать func.__defaults__ или подменить функцию в чужом модуле (monkey patching). Удобно в тестах, катастрофа в библиотеке.
  • Вызовы дорогие. Архитектура из мелких функций, естественная для Go, в Python заметно бьёт по горячим циклам — оттуда и векторизация numpy, и вынос циклов в C.
  • Ошибки сигнатур ловятся в рантайме. Без mypy/ruff в CI опечатка в имени именованного аргумента доживёт до продакшена.

Вывод не «Python плохой», а «инструмент выбирают под задачу». Метапрограммирование — сильная сторона; используйте его для инфраструктуры и границ приложения, а ядро бизнес-логики держите на скучных явных функциях с аннотациями.

Типичные ошибки — сводная таблица

Симптом Причина Что делать
Дефолтный список «помнит» прошлые вызовы __defaults__ вычислен один раз на def = None + if x is None, sentinel-объект
UnboundLocalError до присваивания присваивание сделало имя локальным во всём теле global/nonlocal либо другое имя
Все замыкания вернули последнее значение захвачена ячейка, а не значение lambda i=i:, фабрика, partial
NameError для атрибута класса в методе тело class — не enclosing scope self.attr или Class.attr
NameError на exc после except имя удаляется в конце блока скопировать значение в другую переменную
__name__ обёртки вместо исходной функции нет functools.wraps всегда @functools.wraps(fn)
Декоратор вернул функцию вместо результата @deco вместо @deco() параметры фабрики — keyword-only
Декоратор-класс не работает на методах нет __get__, не дескриптор реализовать __get__ или взять wrapt
coroutine was never awaited синхронная обёртка над async def ветка через inspect.iscoroutinefunction
Память растёт, объекты не собираются @cache на методе держит self кеш вне класса, cached_property, weakref
TypeError: unhashable type в кеше список/dict в аргументах кортежи, frozenset, ключ-строка
mypy молчит на декорированной функции Callable[..., Any] стёр сигнатуру ParamSpec / PEP 695
RecursionError на больших данных нет TCO, лимит 1000 переписать итеративно с явным стеком
Функция «внезапно» ведёт себя иначе monkey patching или мутация __defaults__ запретить в код-ревью, изолировать в тестах

Чек-лист код-ревью

  • Мутабельных значений по умолчанию нет; ruff с правилом B006 включён в CI.
  • Булевы и опциональные параметры — keyword-only (* в сигнатуре).
  • *args/**kwargs только в честных обёртках, не в бизнес-логике.
  • Каждый декоратор имеет @functools.wraps и типизирован через ParamSpec.
  • Параметры фабрик декораторов keyword-only — забытые скобки падают сразу.
  • Декоратор не проглатывает исключения и не подменяет аргументы молча.
  • Нет global для изменяемого состояния; конфиг передаётся явно.
  • Замыкания не держат тяжёлые объекты дольше нужного.
  • Кеширующие декораторы имеют осознанный maxsize, не висят на методах, не кешируют изменчивые данные.
  • Рекурсия ограничена по глубине или переписана итеративно.

Мини-итог

def создаёт объект; объект склеивает неизменяемый код с изменяемым окружением — глобалями, дефолтами, ячейками. Компилятор решает судьбу каждого имени статически, поэтому LEGB — это не поиск по словарям, а выбор одного из трёх опкодов. Замыкание захватывает ячейку, а не значение, и продлевает жизнь всему, до чего дотянулось. Декоратор — обычное присваивание, выполненное при импорте; вся его сложность в порядке применения, в сохранении метаданных и в честной типизации. Держите эти четыре факта в голове — и странное поведение Python перестанет быть странным.

Источники

Что дальше

ООП в Python: классы, наследование, MRO, дескрипторы, dataclasses — там мы увидим, что метод это функция плюс дескриптор, что @property и @classmethod устроены ровно по разобранным здесь правилам, и как MRO превращает наследование в предсказуемый линейный список.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов