2025, Oct 04 03:16

Композиция декораторов в Python: почему сбрасывается nonlocal и как это исправить

Разбираем, почему при композиции декораторов в Python сбрасывается nonlocal-состояние из-за переобёртки и как это исправить: оборачивать при декорировании.

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

Воспроизведение проблемы

Ниже — минимальный пример, где дочерний декоратор опирается на родительский, который хранит переключатель в nonlocal-переменной. Задача — выполнить разную логику при первом и последующих вызовах, но выполнение так и не переходит в ветку “после”.

def base_decorator(fn):
primed = False
def inner(*a, **kw):
nonlocal primed
if primed:
print("executing after first call")
return fn(*a, **kw)
else:
print("executing before first call")
primed = True
out = fn(*a, **kw)
return out
return inner

def should_allow():
return True

def child_decorator(fn):
def inner(*a, **kw):
if should_allow():
composed = base_decorator(fn)
return composed(*a, **kw)
else:
print("do nothing")
return inner

class Runner:
def __init__(self):
print("init called")

@child_decorator
def run(self):
print("Hello from run")

obj = Runner()
obj.run()
obj.run()

Что происходит

Nonlocal-переменная живёт внутри замыкания, созданного родительским декоратором. В приведённом коде дочерний декоратор вызывает родительский внутри тела обёрнутой функции. Это означает, что при каждом вызове создаётся новое замыкание. Каждый раз состояние nonlocal начинается «с нуля», поэтому проверка постоянно попадает в ветку “до” и никогда не доходит до “после”.

Решение

Оборачивайте один раз — в момент декорирования, а не при вызове. Вынесите обращение к родительскому декоратору за пределы внутренней функции, чтобы замыкание (и его nonlocal-флаг) создавалось один раз и сохранялось между вызовами.

def child_decorator(fn):
composed = base_decorator(fn)
def inner(*a, **kw):
if should_allow():
return composed(*a, **kw)
else:
print("do nothing")
return inner

С этими правками первый вызов пойдёт по ветке “до” и переключит флаг, а последующие — попадут в ветку “после”, как и задумано. Родительский декоратор при этом менять не нужно.

Почему это важно

Поведение декораторов зависит от того, когда выполняется обёртка. Если каждый раз переоборачивать в процессе вызова, вы заново создаёте всё состояние замыкания, включая nonlocal-переменные, которые отслеживают первый запуск или кешированное состояние. Для декораторов, отвечающих за инициализацию, контроль доступа или одноразовую настройку, эта разница принципиальна: так вы избегаете скрытых багов и дублирования работы.

Выводы

Комбинируя декораторы, следите, чтобы обёртка выполнялась один раз — при декорировании, — тогда замыкания сохранят стабильное состояние на всём протяжении жизни функции. Если родительский декоратор хранит nonlocal-состояние, выносите интеграцию дочернего декоратора за пределы горячего пути и просто проксируйте вызовы уже обёрнутой функции. Такой подход делает поведение предсказуемым и избавляет от неожиданных «сбросов».

Статья основана на вопросе на StackOverflow от Rohit Pathak и ответе от Abdul Aziz Barkat.