Функции, области видимости, замыкания, декораторы
Синтаксис 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), имя считается локальным во всём теле, с первой строки.
Отсюда самая частая ошибка новичков в чужом коде:
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',)
Два вывода, которые надо запомнить:
- Замыкание захватывает переменную, а не значение. Все функции, созданные в одном вызове, делят общие ячейки.
- Замыкание удерживает объект от сборки мусора. Если вы захватили в лямбду огромный 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 Language Reference — Execution model — формальные правила связывания имён и областей видимости.
- Python Language Reference — Function definitions — грамматика
def, декораторов и параметров. functools,inspect,operator— рабочие лошадки этой главы.- PEP 227 — Statically Nested Scopes и PEP 3104 —
nonlocal— как замыкания появились в языке. - PEP 318 — Decorators, PEP 570 — Positional-Only Parameters, PEP 3102 — Keyword-Only Arguments.
- PEP 612 — ParamSpec, PEP 695 — Type Parameter Syntax.
- PEP 709 — Inlined comprehensions, PEP 667 — Consistent views of namespaces, PEP 659 — Specializing Adaptive Interpreter.
- Luciano Ramalho, «Fluent Python», 2nd ed. — главы 7–9 остаются лучшим разбором функций, замыканий и декораторов.
- David Beazley, «Python Distilled» — сжатое и точное изложение модели исполнения.
- Beazley & Jones, «Python Cookbook», 3rd ed. — глава 9 целиком про метапрограммирование.
- Graham Dumpleton, «How you implemented your Python decorator is wrong» и библиотека
wrapt. - Real Python — Primer on Python Decorators — практичный сборник рецептов.
Что дальше
ООП в Python: классы, наследование, MRO, дескрипторы, dataclasses — там мы увидим, что метод это функция плюс дескриптор, что @property и @classmethod устроены ровно по разобранным здесь правилам, и как MRO превращает наследование в предсказуемый линейный список.