В Rust можно создавать не только именованные функции, но и анонимные, которые называются замыканиями. Сами по себе они не так уж интересны, пока вы не объединяете их с функциями, которые принимают замыкания в качестве аргументов. Вот где реальная мощь!
Давайте создадим замыкание:
let add_one = |x| { 1 + x };
println!("The sum of 5 plus 1 is {}.", add_one(5));
Для этого используем синтаксис |...| { ... }
, а затем создаём привязку в коде для его более позднего применения. Обратите внимание: мы вызываем функцию с помощью имени привязки и двух круглых скобок, точно так же мы поступаем и при вызове именованной функции.
Давайте сравним синтаксис. Он практически идентичен:
let add_one = |x: i32| -> i32 { 1 + x };
fn add_one (x: i32) -> i32 { 1 + x }
Как вы могли заметить, замыкания выводят аргумент и возвращают типы, поэтому объявлять его не нужно. Это отличает их от именованных функций, которые по умолчанию принимают значения, возвращающиеся в круглых скобках ()
.
Между замыканием и именованными функциями есть и ещё одно большое различие, оно обусловлено названием: замыкание «замыкает своё окружение», захватывая его переменные. Что это значит? Посмотрите:
fn main() {
let x: i32 = 5;
let printer = || { println!("x is: {}", x); };
printer(); // выводит «x is: 5»
}
Синтаксис ||
указывает на то, что это анонимное замыкание, которое не принимает никаких аргументов. Без него у нас был бы просто блок кода в фигурных скобках {}
.
Другими словами, замыкание получает доступ к переменным из области видимости, в которой оно определено. Замыкание заимствует любые используемые им переменные, поэтому здесь у нас возникнет ошибка:
fn main() {
let mut x: i32 = 5;
let printer = || { println!("x is: {}", x); };
x = 6; // ошибка: не удаётся присвоить значение переменной «x», так как она заимствована
}
Перемещающие замыкания
В Rust есть и второй тип замыкания — перемещающее. На перемещающие замыкания указывает ключевое слово move
(например, move || x * x
). Разница между перемещающим замыканием и обычным заключается в том, что первое всегда забирает во владение все переменные, которые оно использует. Второе лишь создаёт ссылку на стековый фрейм, охватывающий её окружение. Перемещающие замыкания широко применяются в сочетании с функциональными средствами Rust, использующимися при одновременном выполнении нескольких задач.
Замыкания в качестве аргументов
Наибольший интерес замыкания представляют в роли аргумента для другой функции. Вот пример:
fn twice<F: Fn(i32) -> i32>(x: i32, f: F) -> i32 {
f(x) + f(x)
}
fn main() {
let square = |x: i32| { x * x };
twice(5, square); // оказывается равным 50
}
Разберём его, начиная с main
:
let square = |x: i32| { x * x };
Это мы уже видели в начале статьи. Создаём замыкание, которое принимает целочисленное значение и возвращает его квадрат.
twice(5, square); // оказывается равным 50
А эта строчка поинтереснее. Здесь мы вызываем функцию twice
и передаем ей два аргумента: целое число 5
и замыкание square
. Работает это точно так же, как передача в функцию любых других двух привязок переменных, но если вы никогда раньше с замыканиями дела не имели, то может показаться немного сложновато. Тогда просто представьте, что передаёте две переменные: одна переменная — это i32, а другая — функция.
Теперь посмотрим, как определяется twice
:
fn twice<F: Fn(i32) -> i32>(x: i32, f: F) -> i32 {
twice
принимает два аргумента: x
и f
. Потому-то мы и вызывали его с двумя аргументами. x
— это i32
, мы делали это много раз. А вот f
— это функция, которая принимает i32
и возвращает i32
. Именно на это указывает требование Fn(i32) -> i32
к параметру типа F
. То есть F
представляет собой любую функцию, которая принимает i32
и возвращает i32
.
Это пока что самая сложная сигнатура функции из тех, что мы видели! Но немного изучения и практики — и всё будет понятно. Ваши усилия окупятся, ведь такая передача замыкания может быть очень эффективной. Со всей той информацией о типах, которая становится доступной во время компиляции, компилятор может творить чудеса.
В итоге twice
тоже возвращает i32
.
Посмотрим теперь на тело функции twice
:
fn twice<F: Fn(i32) -> i32>(x: i32, f: F) -> i32 {
f(x) + f(x)
}
Замыкание называется f
, поэтому мы можем вызывать его точно так же, как и предыдущие. Передаём аргумент x
в каждое из них. Отсюда и название функции twice
, что означает «дважды».
Выполнив вычисления, получим такой результат: (5 * 5) + (5 * 5) == 50
.
Эту технику стоит освоить, поскольку стандартная библиотека Rust широко использует замыкания.
Название square
можно и задавать, а просто сделать его значение подставляемым. Вот точно такой же пример, как и предыдущий:
fn twice<F: Fn(i32) -> i32>(x: i32, f: F) -> i32 {
f(x) + f(x)
}
fn main() {
twice(5, |x: i32| { x * x }); // оказывается равным 50
}
Название именованной функции можно использовать везде, где применяется замыкание. Этот же пример можно записать по-другому:
fn twice<F: Fn(i32) -> i32>(x: i32, f: F) -> i32 {
f(x) + f(x)
}
fn square(x: i32) -> i32 { x * x }
fn main() {
twice(5, square); // оказывается равным 50
}
Такое встречается нечасто, но время от времени можно делать и так.
И в заключение рассмотрим функцию, которая принимает два замыкания:
fn compose<F, G>(x: i32, f: F, g: G) -> i32
where F: Fn(i32) -> i32, G: Fn(i32) -> i32 {
g(f(x))
}
fn main() {
compose(5,
|n: i32| { n + 42 },
|n: i32| { n * 2 }); // оказывается равным 94
}
Вы можете задаться вопросом: зачем здесь два параметра типа F
и G
, ведь оба они имеют одну и ту же сигнатуру: Fn(i32) -> i32
.
А всё потому, что в Rust у каждого замыкания свой уникальный тип. Мало того, что замыкания с разными сигнатурами имеют разные типы, так ещё и у замыканий с одной и той же сигнатурой тоже разные типы!
Для простоты можно считать, что поведение замыкания — это часть его типа. Поэтому при использовании одного параметра типа для обоих замыканий будет принято первое из них и отвергнуто второе. Уникальный тип второго замыкания не позволяет ему быть представленным тем же параметром типа, что и у первого. Поэтому мы и используем два разных типа параметров: F
и G
.
Здесь также появляется оператор where
, дающий возможность более гибко описывать параметры типа.
Вот и всё, что нужно знать, чтобы освоить замыкания! На первый взгляд они кажутся немного странными, но стоит только к ним привыкнуть, как вам будет недоставать их в других языках. Передача функций другим функциям обладает невероятной мощью, убедитесь сами!
Читайте также:
- Диагностика кода на Rust
- Как спроектировать REST API для выполнения системных команд с помощью Actix Rust
- Rust: работа с потоками
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Omar Faroque: Best explanation of closure in Rust