Python славится своей гибкостью. Мы можем передавать функции как аргументы, возвращать их из других функций и даже "записывать" внутрь них состояние. Но как это работает под капотом? И при чем тут странное слово nonlocal?

В этой статье мы проследим эволюцию: начнем с глобальных переменных, разберемся с замыканиями (closures), поймем магию nonlocal, а затем соберем всё это вместе, чтобы понять, как работают декораторы — один из самых элегантных механизмов Python.

Часть 1. Глобальное vs Локальное: LEGB и первые проблемы

В Python есть четкая иерархия областей видимости — правило LEGB:

  • Local — локальная область функции

  • Enclosing — объемлющая функция (функция внутри функции)

  • Global — глобальная область модуля

  • Built-in — встроенные имена (print, len и т.д.)

Простой пример:

x = "глобальная"  # Global

def outer():
    x = "объемлющая"  # Enclosing
    
    def inner():
        x = "локальная"  # Local
        print(x)
    
    inner()
    print(x)

outer()
print(x)

Вывод:

локальная
объемлющая
глобальная

Всё логично: каждая функция видит "свою" переменную. Но что, если мы хотим изменить переменную из объемлющей функции внутри вложенной?

Часть 2. Nonlocal: Доступ к родителю

Представим счетчик, который должен считать вызовы функции:

def counter():
    count = 0
    
    def increment():
        count += 1  # Ошибка!
        return count
    
    return increment

c = counter()
print(c())

Этот код упадет с UnboundLocalError: local variable 'count' referenced before assignment. Почему? Потому что count += 1 интерпретируется как создание новой локальной переменной count внутри increment, а не использование внешней.

Решение — nonlocal:

def counter():
    count = 0
    
    def increment():
        nonlocal count  # Говорим: "используй переменную из объемлющей функции"
        count += 1
        return count
    
    return increment

c1 = counter()
print(c1())  # 1
print(c1())  # 2
print(c1())  # 3

c2 = counter()  # Новый счетчик с нуля
print(c2())  # 1

nonlocal поднимается на уровень выше (но не до глобальной!). Для глобальных переменных есть global.

Аналогия: nonlocal — это как ключ от комнаты родителей. Вы можете войти и изменить там вещи, но вы все еще не на улице (глобальной области).

Часть 3. Замыкания (Closures): Функция, которая помнит

А теперь посмотрим на пример выше. Функция counter отработала и завершилась. Переменная count должна была бы умереть. Но функция increment, которую мы вернули, продолжает иметь к ней доступ!

Это и есть замыкание (closure) — функция, которая "захватила" переменные из своей внешней области видимости и продолжает их видеть даже после завершения внешней функции.

Как это выглядит изнутри:

def make_multiplier(n):
    def multiplier(x):
        return x * n
    return multiplier

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(5))  # 10
print(triple(5))  # 15

# Магия: смотрим, что хранит функция
print(double.__closure__[0].cell_contents)  # 2
print(triple.__closure__[0].cell_contents)  # 3

У каждой функции есть атрибут closure, который хранит ячейки с захваченными переменными. double и triple — это не просто функции, это функции с личными данными.

Где используются замыкания:

  1. Фабрики функций (как в примере выше)

  2. Сохранение состояния без классов (счетчики, кэши)

  3. Декораторы (о них дальше)

Часть 4. Подводные камни замыканий

Позднее связывание (Late Binding)

Классическая ловушка с лямбдами в циклах:

funcs = []
for i in range(3):
    funcs.append(lambda: i)

for f in funcs:
    print(f())  # 2, 2, 2 (а ожидали 0, 1, 2)

Почему? Лямбды захватили переменную i, но не ее значение. К моменту вызова функций цикл уже завершился и i равна 2.

Решение через аргумент по умолчанию (фиксация значения):

funcs = [lambda x=i: x for i in range(3)]  # Значение i фиксируется в момент создания

Решение через nonlocal (редко):

def make_func(i):
    return lambda: i

funcs = [make_func(i) for i in range(3)]  # Создаем отдельную область видимости

Часть 5. Декораторы: Замыкания в действии

Декоратор — это функция, которая принимает другую функцию и возвращает ее модифицированную версию. И это чистое замыкание!

Простейший декоратор:

def logger(func):
    def wrapper(*args, **kwargs):
        print(f"Вызов функции {func.__name__} с аргументами {args} {kwargs}")
        result = func(*args, **kwargs)
        print(f"Результат: {result}")
        return result
    return wrapper

@logger
def add(a, b):
    return a + b

add(2, 3)
# Вызов функции add с аргументами (2, 3) {}
# Результат: 5

Что здесь произо��ло:

  1. @logger — это синтаксический сахар для add = logger(add)

  2. logger принимает функцию add и возвращает функцию wrapper

  3. wrapper — замыкание, которое захватило func (исходную функцию)

  4. Теперь имя add указывает на wrapper, который перед вызовом печатает логи

Декоратор с аргументами

Иногда нужно параметризовать декоратор. Тогда создается еще один уровень вложенности:

def repeat(n):
    def decorator(func):
        def wrapper(*args, **kwargs):
            for _ in range(n):
                result = func(*args, **kwargs)
            return result
        return wrapper
    return decorator

@repeat(3)
def say_hello():
    print("Привет!")

say_hello()  # Привет! Привет! Привет!

Разбираем по уровням:

  • repeat(3) возвращает decorator

  • @decorator применяется к функции

  • decorator возвращает wrapper

  • wrapper — замыкание, захватившее func и n

Часть 6. Сохраняем лицо: @wraps

Есть проблема: декорированная функция теряет свое имя и документацию.

@logger
def add(a, b):
    """Складывает два числа"""
    return a + b

print(add.__name__)  # wrapper 
print(add.__doc__)   # None

Решение — functools.wraps:

from functools import wraps

def logger(func):
    @wraps(func)  # Копирует имя, docstring и другие атрибуты
    def wrapper(*args, **kwargs):
        print(f"Вызов {func.__name__}")
        return func(*args, **kwargs)
    return wrapper

@logger
def add(a, b):
    """Складывает два числа"""
    return a + b

print(add.__name__)  # add
print(add.__doc__)   # Складывает два числа

@wraps — это декоратор для декоратора, который обновляет метаданные.

Часть 7. Несколько декораторов: Матрешка

Порядок применения декораторов важен:

def bold(func):
    @wraps(func)
    def wrapper():
        return f"<b>{func()}</b>"
    return wrapper

def italic(func):
    @wraps(func)
    def wrapper():
        return f"<i>{func()}</i>"
    return wrapper

@bold
@italic
def hello():
    return "Привет"

print(hello())  # <b><i>Привет</i></b>

Работает снизу вверх: сначала italic, потом bold. То есть hello = bold(italic(hello)).

Часть 8. Класс как декоратор

Декоратором может быть не только функция, но и класс — через метод call:

class Counter:
    def __init__(self, func):
        self.func = func
        self.count = 0
    
    def __call__(self, *args, **kwargs):
        self.count += 1
        print(f"Функция вызвана {self.count} раз")
        return self.func(*args, **kwargs)

@Counter
def say(word):
    print(word)

say("Привет")  # Функция вызвана 1 раз
say("Пока")    # Функция вызвана 2 раз

Здесь класс хранит состояние в атрибуте count — это альтернатива замыканию.

Часть 9. Реальные примеры декораторов

Таймер

import time
from functools import wraps

def timer(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)
        end = time.time()
        print(f"{func.__name__} выполнилась за {end-start:.4f} сек")
        return result
    return wrapper

@timer
def slow_func():
    time.sleep(1)

Кэширование (мемоизация)

def cache(func):
    memo = {}
    @wraps(func)
    def wrapper(n):
        if n not in memo:
            memo[n] = func(n)
        return memo[n]
    return wrapper

@cache
def fibonacci(n):
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

print(fibonacci(40))  # Работает быстро благодаря кэшу

Проверка прав доступа

def requires_permission(permission):
    def decorator(func):
        @wraps(func)
        def wrapper(user, *args, **kwargs):
            if permission not in user.permissions:
                raise PermissionError(f"Нет права {permission}")
            return func(user, *args, **kwargs)
        return wrapper
    return decorator

@requires_permission("admin")
def delete_user(user, target_id):
    print(f"Пользователь {target_id} удален")

Часть 10. Встроенные декораторы

Python поставляется с готовыми декораторами:

Декоратор

Назначение

@staticmethod

Метод, не получающий self

@classmethod

Метод, получающий класс вместо self

@property

Метод, который вызывается как атрибут

@functools.lru_cache

Кэширование результатов

@dataclass

Генерация initrepr и т.д.

Заключение: Цепочка понятий

Мы прошли путь:

  1. Области видимости → узнали, где живут переменные

  2. nonlocal → научились изменять переменные из объемлющей функции

  3. Замыкания → поняли, как функция может хранить состояние

  4. Декораторы → применили замыкания для модификации функций

Эти концепции — не просто теория. Они используются в каждом втором фреймворке (Flask, Django, FastAPI), в библиотеках для логирования, кэширования, валидации. Понимая их, вы перестаете видеть магию и начинаете видеть инструменты.

Главный вывод: Замыкания и декораторы — это способ писать код, который изменяет код. Это метапрограммирование в действии, и теперь вы знаете, как оно работает изнутри.

Если тема функций в Python вам интересна, больше практических примеров и разборов вы найдете на моем курсе на Stepik.

В моем Telegram-канале я публикую дополнительные материалы по Python и backend-разработке. Там вы можете найти разборы тем, практические примеры, объяснения сложных концепций простым языком и продолжения подобных статей.