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.