Замыкания и лямбды
В главе о векторизации мы уже писали фигурные скобки после списка аргументов и не объясняли, что это. Пора объяснить.
Почему нельзя просто взять переменную
Заголовок раздела «Почему нельзя просто взять переменную»В 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
Список захвата — это и есть то, что превращает вложенную функцию в замыкание.
Пять способов захватить
Заголовок раздела «Пять способов захватить»imm — читаем чужое
Заголовок раздела «imm — читаем чужое»Ссылка только для чтения. Замыкание видит текущее значение, а не то, что было при объявлении:
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
Пятнадцать сначала больше порога, потом меньше — замыкание ничего не копировало, оно смотрит на живую переменную.
mut — меняем чужое
Заголовок раздела «mut — меняем чужое»def main(): var total = 0
def add(x: Int) {mut total}: total += x
add(5) add(7) print(total)12
Это прямой аналог питоновского nonlocal, только объявленный
в сигнатуре, а не внутри тела.
var — берём копию
Заголовок раздела «var — берём копию»Копия делается в момент объявления замыкания. Дальше внешняя переменная живёт своей жизнью:
def main(): var snapshot = 1
def frozen() {var snapshot} -> Int: return snapshot
snapshot = 99 print(frozen(), snapshot)1 99
Замыкание помнит единицу, хотя переменная давно стала девяносто девятью.
Это то, чего в Python приходится добиваться трюком с аргументом
по умолчанию (def f(x=snapshot)).
var name^ — забираем себе
Заголовок раздела «var name^ — забираем себе»Значение переезжает внутрь замыкания, и снаружи его больше нет:
def main(): var data = String("важное")
def take() {var data^}: print(data)
take()важное
Попытка прочитать data после объявления замыкания даст знакомую
по разделу о владении ошибку:
error: use of uninitialized value 'data'Нужно это, чтобы не копировать дорогое значение: список или строку замыкание забирает себе, а не дублирует.
ref — наследует чужую изменяемость
Заголовок раздела «ref — наследует чужую изменяемость»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 inparentheses, 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 объявляет свой аргумент
ровно так же.
Всё вместе
Заголовок раздела «Всё вместе»Отбор заказов: каждый способ захвата здесь нужен по делу.
# Отбор и подсчёт заказов замыканиями.# Каждый способ захвата здесь нужен по делу, а не для показа.
@fieldwise_initstruct 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.
Тексты курса — CC BY-NC-SA 4.0, код примеров — Apache 2.0