Замыкания и lambda: nonlocal, partial, functools
В прошлых уроках видели вложенные функции и упоминали замыкания. В этом уроке - подробно: как closures захватывают переменные, чем это полезно, как пишутся лямбды и какие готовые утилиты даёт functools для функционального стиля.
Что такое closure
Closure - это функция, которая «помнит» переменные из окружения, в котором была создана. Это происходит автоматически, когда вложенная функция использует переменную из внешней:
def make_greeter(greeting):
def greeter(name):
return f"{greeting}, {name}!"
return greeter
hello = make_greeter("Hello")
hi = make_greeter("Hi")
print(hello("Alice")) # Hello, Alice!
print(hi("Bob")) # Hi, Bob!
greeter захватывает greeting из make_greeter. После возврата make_greeter уже завершилась, но greeting всё ещё доступна для greeter - это и есть closure.
Захват по ссылке, не по значению
Важно: closure захватывает ссылку на переменную, не её значение в момент создания.
def make_funcs():
funcs = []
for i in range(3):
funcs.append(lambda: i)
return funcs
fs = make_funcs()
print([f() for f in fs]) # [2, 2, 2] - не [0, 1, 2]!
Все три лямбды захватили одну и ту же переменную i. К моменту вызова f() цикл завершился, и i = 2.
Исправление через default argument:
def make_funcs():
funcs = []
for i in range(3):
funcs.append(lambda i=i: i) # значение i фиксируется как default
return funcs
fs = make_funcs()
print([f() for f in fs]) # [0, 1, 2]
i=i создаёт параметр i с дефолтным значением, равным текущему i. Это вычисляется в момент создания lambda, поэтому каждая получает свой i.
Альтернатива - functools.partial:
from functools import partial
def identity(x):
return x
def make_funcs():
return [partial(identity, i) for i in range(3)]
fs = make_funcs()
print([f() for f in fs]) # [0, 1, 2]
Модификация захваченной переменной: nonlocal
По умолчанию closure может читать захваченную переменную, но не писать в неё:
def counter():
count = 0
def increment():
count += 1 # UnboundLocalError!
return count
return increment
c = counter()
c() # ошибка
count += 1 интерпретируется как присваивание count = count + 1, что создаёт локальную count, маскирующую внешнюю. Решение - явное nonlocal:
def counter():
count = 0
def increment():
nonlocal count
count += 1
return count
return increment
c = counter()
print(c(), c(), c()) # 1 2 3
nonlocal count говорит: «переменная count не локальная, ищи в окружающей области».
def counter():
state = {"count": 0}
def increment():
state["count"] += 1
return state["count"]
return increment
Изменение элемента словаря не требует nonlocal - сам словарь не переприсваивается, только модифицируется. Стиль немного устаревший, но всё ещё встречается.
Когда использовать closures
1. Параметризованные функции:
def power(exponent):
def inner(base):
return base ** exponent
return inner
square = power(2)
cube = power(3)
square(5) # 25
cube(3) # 27
2. Сокрытие состояния:
def make_db():
rows = {}
def insert(id, data):
rows[id] = data
def get(id):
return rows.get(id)
return insert, get
insert, get = make_db()
insert(1, "Alice")
get(1) # 'Alice'
rows инкапсулирован - снаружи к нему нельзя обратиться напрямую.
3. Кешированные значения:
def memoize(func):
cache = {}
def wrapper(arg):
if arg not in cache:
cache[arg] = func(arg)
return cache[arg]
return wrapper
Это упрощённый декоратор кеширования (подробно про декораторы в следующем уроке).
lambda - анонимные функции
lambda - короткая запись для функций из одного выражения:
square = lambda x: x ** 2
square(5) # 25
# Эквивалент через def
def square(x):
return x ** 2
Структура: lambda <params>: <expression>. Параметры до двоеточия, выражение после. Только одно выражение - никаких statement, никакого return.
Где обычно применяется:
# Сортировка с ключом
users = [{"name": "Alice", "age": 30}, {"name": "Bob", "age": 25}]
sorted(users, key=lambda u: u["age"])
# map / filter
list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]
list(filter(lambda x: x > 0, [-1, 2, -3, 4])) # [2, 4]
# Однострочные callbacks
button.on_click = lambda: print("clicked")
PEP 8 не рекомендует присваивать lambda переменной: f = lambda: .... Лучше обычный def f(): ....
functools - утилиты для функций
partial - частичное применение
partial фиксирует часть аргументов функции, возвращая новую с меньшим числом параметров:
from functools import partial
def power(base, exponent):
return base ** exponent
square = partial(power, exponent=2)
cube = partial(power, exponent=3)
square(5) # 25
cube(3) # 27
Это альтернатива closure - часто короче и понятнее.
reduce - свёртка
reduce сводит коллекцию к одному значению через бинарную функцию:
from functools import reduce
reduce(lambda a, b: a + b, [1, 2, 3, 4, 5]) # 15
reduce(lambda a, b: a + b, [1, 2, 3, 4, 5], 100) # 115 (с initial)
reduce(lambda a, b: a * b, [1, 2, 3, 4, 5]) # 120 (факториал)
В Python функциональный стиль не доминирующий, и часто sum(), max(), min(), any(), all() уже встроены - reduce реже нужен.
lru_cache - мемоизация
Декоратор, кеширующий результаты функции:
from functools import lru_cache
@lru_cache(maxsize=128)
def fibonacci(n):
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
fibonacci(50) # быстро, благодаря кешу
Без cache рекурсивная реализация Фибоначчи экспоненциальная. С @lru_cache - линейная. Почему так, разбираем в уроке про рекурсию. Часто применяется для дорогих pure-функций.
wraps - сохранение метаданных декорируемой функции
Используется при создании декораторов (подробно в уроке 20):
from functools import wraps
def logged(func):
@wraps(func)
def wrapper(*args, **kwargs):
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
Без @wraps метаданные (__name__, __doc__) терялись бы, что плохо для отладки.
operator - функциональные эквиваленты операторов
Модуль operator даёт функции-аналоги операторов и часто заменяет лямбды:
from operator import attrgetter, itemgetter, methodcaller
users = [User("Alice", 30), User("Bob", 25)]
# Сортировка по атрибуту
sorted(users, key=attrgetter("age")) # вместо lambda u: u.age
# Сортировка по элементу
items = [("apple", 5), ("banana", 2)]
sorted(items, key=itemgetter(1)) # вместо lambda x: x[1]
# Вызов метода
strings = ["Hello", "World"]
list(map(methodcaller("upper"), strings)) # вместо lambda s: s.upper()
operator функции быстрее лямбд и читабельнее. Используй когда подходит.
Сравнение с Go
В Go функции и closures тоже first-class:
func makeCounter() func() int {
count := 0
return func() int {
count++
return count
}
}
c := makeCounter()
fmt.Println(c(), c(), c()) // 1 2 3
В Go нет nonlocal - захваченная переменная и так модифицируется без специального ключевого слова. Это исторические различия дизайна, но семантика похожа.
Распространённые ошибки
1. Late binding в loop closures.
Уже видели выше. Используй default argument или partial.
2. Запись в захваченную переменную без nonlocal.
def outer():
count = 0
def inner():
count = count + 1 # UnboundLocalError
inner()
Нужно nonlocal count.
3. Lambda для сложной логики.
# Плохо - нечитаемо
sorted(data, key=lambda x: (x["category"], -x["priority"] if x["urgent"] else x["priority"]))
# Лучше
def sort_key(item):
priority = -item["priority"] if item["urgent"] else item["priority"]
return (item["category"], priority)
sorted(data, key=sort_key)
Мини-задание
- Counter через nonlocal:
def make_counter(initial=0):
count = initial
def increment(step=1):
nonlocal count
count += step
return count
return increment
c = make_counter(10)
print(c()) # 11
print(c(5)) # 16
print(c()) # 17
- Late binding pitfall:
# Создай 5 функций, каждая возвращает свой индекс
# Используй default argument для правильного захвата
funcs = [lambda i=i: i for i in range(5)]
print([f() for f in funcs]) # [0, 1, 2, 3, 4]
- Используй partial и operator:
from functools import partial
from operator import mul
# Создай функцию для удвоения
double = partial(mul, 2)
print(double(5)) # 10
print(double(7)) # 14
Что дальше
Освоили closures и lambda. В следующем уроке - декораторы: продолжение темы функций как объектов. Декораторы - один из самых выразительных инструментов Python и широко используются в фреймворках.