Замыкания в Python
Как работают замыкания в Python: вложенные функции, захват переменных, nonlocal, типичные ошибки и практические примеры с запуском кода.
Замыкание — это вложенная функция, которая помнит переменные из охватывающей области видимости, в которой она была определена, даже после того как внешняя функция завершила выполнение. Замыкания позволяют прикрепить приватное состояние к функции без использования класса — это делает их одним из самых элегантных инструментов Python для создания коллбэков, фабрик и функций с состоянием.
На этой странице рассматривается принцип работы замыканий, три условия, которые они требуют, типичные подводные камни и практические сценарии использования.
Что такое замыкание?
Когда Python выполняет функцию, он создаёт локальную область видимости, которая исчезает после возврата функции. Обычно все определённые там переменные уничтожаются. Замыкание — исключение: если внутренняя функция ссылается на переменную из внешней функции, Python сохраняет эту переменную живой в специальном объекте-ячейке (cell object), а внутренняя функция несёт ссылку на эти ячейки куда бы ни передавалась.
Простейшее замыкание — это фабрика функций: функция, которая создаёт и возвращает другую функцию:
def make_multiplier(factor):
def multiply(n):
return n * factor # 'factor' is captured from the enclosing scope
return multiply
double = make_multiplier(2)
triple = make_multiplier(3)
print(double(5)) # 10
print(triple(5)) # 15
print(double(10)) # 20Каждый вызов make_multiplier создаёт новое замыкание с собственной независимой копией factor. double и triple полностью независимы, несмотря на то что были созданы одной и той же функцией.
Три условия для замыкания
Функция является замыканием, когда выполняются все три условия:
- Существует вложенная функция — функция, определённая внутри другой функции.
- Вложенная функция ссылается на переменную из охватывающей области видимости — такая переменная называется свободной переменной.
- Охватывающая функция возвращает вложенную функцию (или передаёт её куда-либо ещё).
def outer():
message = 'Hello from outer' # free variable
def inner():
print(message) # inner references it
return inner # outer returns inner
greet = outer()
greet() # Hello from outerПосле того как outer() возвращает управление, её локальный фрейм уничтожается — но message остаётся живым внутри greet.__closure__.
Исследование замыкания
Python открывает доступ к ячейкам замыкания через атрибут __closure__:
def make_adder(n):
def add(x):
return x + n
return add
add5 = make_adder(5)
print(add5(3)) # 8
print(add5.__closure__) # (<cell at 0x...>,)
print(add5.__closure__[0].cell_contents) # 5__closure__ — это кортеж объектов-ячеек, по одной на каждую захваченную переменную. Если функция не является замыканием, __closure__ равно None.
Изменение захваченных переменных с помощью nonlocal
По умолчанию захваченную переменную можно читать, но нельзя переприсваивать. Попытка присвоить ей значение создаст новую локальную переменную, что обычно не является желаемым поведением. Используйте ключевое слово nonlocal, чтобы указать Python, что вы имеете в виду переменную из охватывающей области видимости:
def make_counter(start=0):
count = start
def increment(step=1):
nonlocal count # rebind the enclosing 'count', not a new local
count += step
return count
return increment
counter = make_counter()
print(counter()) # 1
print(counter()) # 2
print(counter(5)) # 7
counter2 = make_counter(10)
print(counter2()) # 11
print(counter()) # 8 — counter is unaffectedКаждый вызов make_counter создаёт независимую ячейку count. counter и counter2 не разделяют состояние.
Для более глубокого изучения того, как Python определяет, к какой области видимости принадлежит переменная, смотрите Область видимости в Python.
Типичная ошибка: замыкания в циклах
Классическая ошибка — создание замыканий внутри цикла с ожиданием, что каждое из них захватит текущее значение переменной цикла:
# Wrong — all functions capture the same 'i' cell
funcs = []
for i in range(3):
funcs.append(lambda: i)
print([f() for f in funcs]) # [2, 2, 2] — not [0, 1, 2]Все три лямбды разделяют одну ячейку, в которой хранится переменная цикла i. К моменту их вызова i достигло своего конечного значения 2.
Исправление 1: аргумент по умолчанию (захват по значению)
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # default arg is evaluated immediately
print([f() for f in funcs]) # [0, 1, 2]Исправление 2: функция-фабрика
def make_func(i):
def f():
return i
return f
funcs = [make_func(i) for i in range(3)]
print([f() for f in funcs]) # [0, 1, 2]Функция-фабрика создаёт новую область видимости — и, следовательно, новую ячейку — для каждой итерации. Это более явный и читаемый подход.
Практические сценарии использования
Частичное применение
Замыкания — лёгковесная альтернатива functools.partial, когда нужна предварительно настроенная версия функции:
def make_power(exponent):
def power(base):
return base ** exponent
return power
square = make_power(2)
cube = make_power(3)
print(square(4)) # 16
print(cube(3)) # 27Простое мемоизирование
Замыкание может содержать словарь-кэш, который сохраняется между вызовами:
def make_memoized(func):
cache = {}
def wrapper(*args):
if args not in cache:
cache[args] = func(*args)
return cache[args]
return wrapper
@make_memoized
def slow_square(n):
return n * n
print(slow_square(4)) # 16
print(slow_square(4)) # 16 (served from cache)
print(slow_square(7)) # 49Этот паттерн — именно то, как работают декораторы Python под капотом: декоратор — это просто замыкание, которое оборачивает другую функцию.
Настройка коллбэков
Замыкания удобны для создания коллбэков, которым нужен некоторый встроенный контекст:
def make_logger(prefix):
def log(message):
print(f'[{prefix}] {message}')
return log
info = make_logger('INFO')
error = make_logger('ERROR')
info('Server started') # [INFO] Server started
error('Disk full') # [ERROR] Disk fullЗамыкания vs. классы
Замыкание и класс с единственным методом часто решают одну и ту же задачу. Выбирайте, исходя из сложности:
| Ситуация | Предпочтительный подход |
|---|---|
| Одно состояние, одно поведение | Замыкание |
| Несколько методов или публичных атрибутов | Класс |
| Требуется сериализация (например, pickle) | Класс |
| Передача коллбэка другой функции | Замыкание |
# Class approach
class Counter:
def __init__(self, start=0):
self.count = start
def increment(self, step=1):
self.count += step
return self.count
# Closure approach
def make_counter(start=0):
count = start
def increment(step=1):
nonlocal count
count += step
return count
return incrementОба подхода дают идентичное поведение. Замыкание короче; класс более очевиден и расширяем.
Заключение
Замыкания позволяют вложенной функции нести собственное приватное состояние, запоминая переменные из области видимости, в которой она была определена. Ключевые моменты:
- Замыкание требует вложенной функции, свободной переменной и того, чтобы внутренняя функция была возвращена или передана дальше.
- Используйте
nonlocal, когда нужно переприсвоить (а не просто прочитать) захваченную переменную. - Избегайте ошибки с переменной цикла: используйте функцию-фабрику или аргумент по умолчанию, чтобы зафиксировать значение на каждой итерации.
- Замыкания лежат в основе декораторов и тесно связаны с правилами области видимости Python.