Skip to content

Замыкания и лямбды

This content is not available in your language yet.

В главе о векторизации мы уже писали фигурные скобки после списка аргументов и не объясняли, что это. Пора объяснить.

В Python вложенная функция берёт что хочет:

def make_counter():
total = 0
def add(x):
nonlocal total
total += x
return add

Работает это потому, что за временем жизни total следит сборщик мусора. В Mojo сборщика нет, и вопрос «а что будет с total, когда внешняя функция закончится» приходится решать явно.

Поэтому вложенная функция, потянувшаяся к внешней переменной без объяснений, не соберётся:

error: Could not infer capture convention of the captured value k
Что это значит

Компилятор не угадывает. Захватить переменную можно пятью разными способами, у каждого своя цена и свои последствия для времени жизни, и выбрать за вас он не берётся.

Как исправить

Написать список захвата — фигурные скобки после списка аргументов:

def main():
var k = 4
def times(x: Int) {imm k} -> Int:
return x * k
print(times(3))
Результат
12

Список захвата — это и есть то, что превращает вложенную функцию в замыкание.

Ссылка только для чтения. Замыкание видит текущее значение, а не то, что было при объявлении:

def main():
var threshold = 10
def is_over(x: Int) {imm threshold} -> Bool:
return x > threshold
print(is_over(15))
threshold = 20
print(is_over(15))
Результат
True
False

Пятнадцать сначала больше порога, потом меньше — замыкание ничего не копировало, оно смотрит на живую переменную.

def main():
var total = 0
def add(x: Int) {mut total}:
total += x
add(5)
add(7)
print(total)
Результат
12

Это прямой аналог питоновского nonlocal, только объявленный в сигнатуре, а не внутри тела.

Копия делается в момент объявления замыкания. Дальше внешняя переменная живёт своей жизнью:

def main():
var snapshot = 1
def frozen() {var snapshot} -> Int:
return snapshot
snapshot = 99
print(frozen(), snapshot)
Результат
1 99

Замыкание помнит единицу, хотя переменная давно стала девяносто девятью. Это то, чего в Python приходится добиваться трюком с аргументом по умолчанию (def f(x=snapshot)).

Значение переезжает внутрь замыкания, и снаружи его больше нет:

def main():
var data = String("важное")
def take() {var data^}:
print(data)
take()
Результат
важное

Попытка прочитать data после объявления замыкания даст знакомую по разделу о владении ошибку:

error: use of uninitialized value 'data'

Нужно это, чтобы не копировать дорогое значение: список или строку замыкание забирает себе, а не дублирует.

ref не выбирает изменяемость сам, а берёт ту, что уже есть у захваченной переменной:

def main():
var total = 0
def bump() {ref total}:
total += 1
bump()
bump()
print(total)
Результат
2

Здесь total объявлена через var, значит изменяема, значит и замыкание может её менять. Если бы переменная пришла неизменяемым аргументом, то же самое замыкание могло бы её только читать.

Форма нужна там, где сама внешняя функция приняла аргумент как ref и решение об изменяемости принимает уже её вызывающий. В остальных случаях берите imm или mut явно — понятнее.

Перечислять все переменные поимённо не обязательно. Одно слово в скобках задаёт правило для всего, что замыкание использует:

ЗаписьСмысл
{imm}всё используемое — по неизменяемой ссылке
{mut}всё используемое — по изменяемой ссылке
{var}всё используемое — копией
{var^}всё используемое — перемещением
{ref}всё используемое — с их собственной изменяемостью
{}ничего не захватывать

Имя без слова означает imm: {threshold} — то же самое, что {imm threshold}. Конвенции можно и смешивать в одном списке: {imm config, mut count, var label}.

def main():
var a = 1
var b = 2
def total() {imm} -> Int:
return a + b
print(total())
Результат
3

Для условия в одну строку заводить отдельную функцию незачем:

def apply[F: def(Int) -> Int, //](value: Int, f: F) -> Int:
return f(value)
def main():
print(apply(7, lambda (x: Int) -> Int: x * 3))
Результат
21

Типы аргументов обязательны — питоновская краткость lambda x: x * 3 не пройдёт, и компилятор прямо скажет, чего хочет:

error: unparenthesized lambda arguments are not supported; write them in
parentheses, with types, e.g. `lambda (x: Int, y: Int) -> Int: x + y`

Чтобы принять замыкание в свою функцию, его тип объявляют выводимым параметром — тем самым // из главы о параметризации:

def apply[F: def(Int) -> Int, //](value: Int, f: F) -> Int:
return f(value)

Так устроены и библиотечные функции: vectorize объявляет свой аргумент ровно так же.

Отбор заказов: каждый способ захвата здесь нужен по делу.

orders.mojo
# Отбор и подсчёт заказов замыканиями.
# Каждый способ захвата здесь нужен по делу, а не для показа.
@fieldwise_init
struct Order(Copyable, Movable):
var id: Int
var amount: Int # в рублях
def count_matching[P: def(Order) -> Bool, //](
orders: List[Order], predicate: P
) -> Int:
"""Функция высшего порядка: условие приходит замыканием."""
var found = 0
for order in orders:
if predicate(order):
found += 1
return found
def main():
var orders = List[Order]()
orders.append(Order(1, 500))
orders.append(Order(2, 12000))
orders.append(Order(3, 3000))
orders.append(Order(4, 45000))
# imm — читаем порог и видим его текущее значение
var threshold = 1000
def is_large(o: Order) {imm threshold} -> Bool:
return o.amount > threshold
print("крупнее", threshold, ":", count_matching(orders, is_large))
threshold = 10000
print("крупнее", threshold, ":", count_matching(orders, is_large))
# mut — накапливаем сумму
var total = 0
def add(o: Order) {mut total}:
total += o.amount
for order in orders:
add(order)
print("всего:", total)
# var — копия на момент объявления, снимок
var limit = 5000
def under_snapshot(o: Order) {var limit} -> Bool:
return o.amount < limit
limit = 1
print("после limit = 1 замыкание всё ещё считает по 5000:")
print(" подходит заказов:", count_matching(orders, under_snapshot))
print(" а limit сейчас:", limit)
# лямбда — для условия в одну строку список захвата не нужен
print("дороже 40000:", count_matching(orders, lambda (o: Order) -> Bool: o.amount > 40000))
Результат
крупнее 1000 : 3
крупнее 10000 : 2
всего: 60500
после limit = 1 замыкание всё ещё считает по 5000:
  подходит заказов: 2
  а limit сейчас: 1
дороже 40000: 1

Главное в этом выводе — четвёртая строка. is_large с {imm threshold} послушно изменил поведение, когда порог поменяли. under_snapshot с {var limit} не изменил: у него своя копия, снятая при объявлении. Один и тот же код, разные скобки — разный смысл.

🎯 Проверь себя

Почему вложенная функция, использующая внешнюю переменную, не собирается без фигурных скобок?

Потому что способов захвата пять, у каждого своя цена и свои последствия для времени жизни, а сборщика мусора, который сгладил бы разницу, в Mojo нет. Компилятор отказывается выбирать за вас и говорит Could not infer capture convention.

Чем `{imm x}` отличается от `{var x}`?

imm — ссылка: замыкание видит текущее значение, включая изменения, сделанные после его объявления. var — копия, снятая в момент объявления: внешняя переменная может меняться сколько угодно, замыкание будет помнить своё.

Лямбда использует внешнюю переменную без списка захвата и компилируется. Вложенная `def` с тем же телом — нет. Почему?

Так устроен язык: лямбда захватывает неявно и по неизменяемой ссылке, def требует явного списка. Практическое следствие: менять захваченное лямбда без списка не может — на попытку получите invalid use of mutating method on rvalue. Если нужен другой захват, список ей тоже пишется, после аргументов.

Как написать свою функцию, принимающую замыкание?

Если замыкание захватывающее — объявить его тип выводимым параметром: def apply[F: def(Int) -> Int, //](value: Int, f: F) -> Int. Руками такой тип не записать: он включает происхождение захваченных переменных. А замыкание без захвата имеет обычный «тонкий» тип и принимается простым аргументом: f: def(Int) thin -> Int.

Хочется вернуть из функции замыкание, помнящее её локальную переменную. Получится?

Нет. Замыкания, переживающие породившую их область видимости, в Mojo не поддерживаются: попытка вернуть захватывающее замыкание роняет компилятор с предложением завести баг-репорт. Вернуть можно только замыкание без захвата — с пустым списком {} и «тонким» типом. Захват {var name^} нужен не для этого, а чтобы не копировать дорогое значение внутри своей же области видимости.

Последняя глава раздела — потоки, процессы и async: как делить работу между ядрами и не сломать общие данные, и что в Mojo 1.1 есть вместо threading, multiprocessing и asyncio.

Примеры проверены на Mojo 1.1.0

Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0