Замыкания и 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 не локальная, ищи в окружающей области».

Старый трюк (до появления nonlocal в Python 3) - использовать список или dict как контейнер:
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")
- Когда логика сложная - используй обычную функцию через `def` - Когда нужны несколько строк - в lambda невозможно - Когда функция переиспользуется - её стоит назвать - Если есть готовая встроенная: `lambda x: x.name` → `operator.attrgetter("name")`

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)

Мини-задание

  1. 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
  1. 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]
  1. Используй 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 и широко используются в фреймворках.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.