Основы Rust: подробно про замыкания (closures)
В нашей последовательной серии материалов мы рассмотрим базовые основы новомодного языка Rust. А во второй части цикла на основе изученного попробуем написать самые простые смарт-контракты для таких блокчейн-проектов, как Solana. В этом туториале будет много примеров, мало теории и быстрый темп продвижения.

Этот пост — вольный перевод на русский вот этой оригинальной статьи (с нашими дополнениями в местах, где это показалось нужным), которую написал Стив Донован. Начало можно найти вот здесь, а оглавление всей серии — вот тут.
Сегодня мы не только пристально рассмотрим «кложуры» (замыкания), но и с учетом всей подготовительной теории готовы более тщательно под новым углом рассмотреть мэтчинг и итераторы, показав, как их можно использовать в продвинутом режиме.
Подробнее о мэтчинге
Напомним, что значения кортежа могут быть извлечены с помощью '()' вот так:
let t = (10,"hello".to_string()); ... let (n,s) = t; // t has been moved. It is No More // n is i32, s is String
Это отдельный случай десериализации данных. Обычно у нас есть некоторые данные и мы хотим либо разделить их на части (как здесь), либо просто позаимствовать некоторые их значения. В любом случае мы извлекаем части структуры.
Синтаксис похож на тот, что используется в match. Здесь мы явно заимствуем значения.
let (ref n,ref s) = t; // n and s are borrowed from t. It still lives! // n is &i32, s is &String
Деструктуризация работает и со структурами:
struct Point {
x: f32,
y: f32
}
let p = Point{x:1.0,y:2.0};
...
let Point{x,y} = p;
// p still lives, since x and y can and will be copied
// both x and y are f32
Пришло время вернуться к матчингу с некоторыми новыми паттернами.
Первые два паттерна в точности повторяют деструктуризацию — они соответствуют не только кортежам с нулевым первым элементом, но любой строке; второй добавляет проверку с if, чтобы он соответствовал только (1, "hello").
Наконец, просто переменная совпадает с любым вариантом. Это полезно, если соответствие применяется к выражению, но вы не хотите привязывать переменную к этому выражению. _ работает как переменная, но игнорируется. Это обычный способ завершить матчинг в Rust.
fn match_tuple(t: (i32,String)) {
let text = match t {
(0, s) => format!("zero {}", s),
(1, ref s) if s == "hello" => format!("hello one!"),
tt => format!("no match {:?}", tt),
// or say _ => format!("no match") if you're not interested in the value
};
println!("{}", text);
}
Почему просто не выполнить сравнение с (1, "hello")?
Но матчинг — дело точное, и компилятор Rust будет жаловаться:
= note: expected type `std::string::String` = note: found type `&'static str`
Зачем нам нужны ref s? Это немного непонятная тонкость для новичка (поищите описание ошибки E00008, там все подробно объясняется), случай незначительной утечки в реализации.
Вот если бы тип был &str, то тогда можно сопоставить напрямую:
match (42,"answer") {
(42,"answer") => println!("yes"),
_ => println!("no")
};
То, что относится к match, относится и к if let. Это классный пример, так как если мы получим Some, мы можем выполнить match внутри него и извлечь из кортежа только строку.
Поэтому здесь нет необходимости во вложенных операторах if let. Мы используем _, потому что нас не интересует первая часть кортежа.
let ot = Some((2,"hello".to_string());
if let Some((_,ref s)) = ot {
assert_eq!(s, "hello");
}
// we just borrowed the string, no 'destructive destructuring'
Интересная проблема возникает при использовании parse (или любой другой функции, которой необходимо определить возвращаемый тип из контекста):
if let Ok(n) = "42".parse() {
...
}
Так какой же тип у n? Вы должны как-то намекнуть — что это за целое число? Является ли оно вообще целым числом?
if let Ok(n) = "42".parse::<i32>() {
...
}
Этот несколько неэлегантный синтаксис называется «оператор турборыбы». Про «турборыбу» понаписано очень много материалов (можно посмотреть, например вот тут), поэтому мы не будем сильно разбирать эту тему здесь.
Если вы находитесь в функции, возвращающей Result, то оператор вопросительного знака предоставляет гораздо более элегантное решение:
let n: i32 = "42".parse()?;
Однако ошибка должна быть преобразована в тип ошибки результата, что мы рассмотрим позже при обсуждении обработки ошибок.
Замыкания
Большая часть возможностей Rust связана с замыканиями. В своей простейшей форме они действуют как короткие функции:
let f = |x| x * x;
let res = f(10);
println!("res {}", res);
// res 100
В этом примере нет явных типов — все выводится, начиная с целочисленного литерала 10.
Мы получим ошибку, если вызовем f на разных типах — по дефолту Rust уже решил, что f должна быть вызвана на целочисленном типе:
let res = f(10);
let resf = f(1.2);
|
8 | let resf = f(1.2);
| ^^^ expected integral variable, found floating-point variable
|
= note: expected type `{integer}`
= note: found type `{float}`
Итак, первый вызов фиксирует тип аргумента x. Это эквивалентно этой функции:
fn f (x: i32) -> i32 {
x * x
}
Но есть большая разница между функциями и замыканиями, помимо необходимости явной типизации. Здесь мы оцениваем линейную функцию:
let m = 2.0;
let c = 1.0;
let lin = |x| m*x + c;
println!("res {} {}", lin(1.0), lin(2.0));
// res 3 5
Нельзя сделать это с помощью явной формы fn — она не знает о переменных в доступной области видимости. Закрывающая форма заимствует m и c из своего контекста.
Каков тип lin? Только rustc знает 🙂 Под капотом замыкание — это структура, которая является вызываемой (дословно «реализует оператор вызова»). Она ведет себя так, как если бы была записана следующим образом:
struct MyAnonymousClosure1<'a> {
m: &'a f64,
c: &'a f64
}
impl <'a>MyAnonymousClosure1<'a> {
fn call(&self, x: f64) -> f64 {
self.m * x + self.c
}
}
Компилятор, конечно, помогает, превращая простой синтаксис замыкания в весь этот код. Но вам необходимо знать, что замыкание — это структура, поэтому она заимствует значения из своего окружения. И поэтому у нее есть время жизни!
Все замыкания — это уникальные типы, но у них есть общие черты. Поэтому, даже если мы не знаем точного типа, мы знаем общее ограничение:
fn apply<F>(x: f64, f: F) -> f64
where F: Fn(f64)->f64 {
f(x)
}
...
let res1 = apply(3.0,lin);
let res2 = apply(3.14, |x| x.sin());
Говоря нормальным языком: apply работает для любого типа T так, что T реализует Fn(f64)->f64 — то есть является функцией, которая принимает f64 и возвращает f64.
После вызова apply(3.0,lin) попытка доступа к lin дает интересную ошибку:
let l = lin;
error[E0382]: use of moved value: `lin`
--> closure2.rs:22:9
|
16 | let res = apply(3.0,lin);
| --- value moved here
...
22 | let l = lin;
| ^ value used here after move
|
= note: move occurs because `lin` has type
`[closure@closure2.rs:12:15: 12:26 m:&f64, c:&f64]`,
which does not implement the `Copy` trait
Вот и все, apply съела наше замыкание. А вот и фактический тип структуры, которую rustc придумал для его реализации. Всегда думать о замыканиях как о структурах — очень полезно.
Вызов замыкания — это вызов метода, который может быть представлен в трех вариациях:
Fn struct passed as &self FnMut struct passed as &mut self FnOnce struct passed as self
Обратите внимание, что mut — f должен быть изменяемым, чтобы это работало.
fn mutate<F>(mut f: F)
where F: FnMut() {
f()
}
let mut s = "world";
mutate(|| s = "hello");
assert_eq!(s, "hello");
Однако не получится избежать правил заимствования. Рассмотрим следующее:
let mut s = "world"; // closure does a mutable borrow of s let mut changer = || s = "world"; changer(); // does an immutable borrow of s assert_eq!(s, "world");
Это невозможно выполнить! Ошибка заключается в том, что мы не можем заимствовать s в операторе assert, потому что ранее оно было заимствовано замыканием changer как mutable.
Пока это замыкание живет, никакой другой код не сможет получить доступ к s, поэтому решением является контроль этого времени жизни путем помещения замыкания в ограниченную область видимости:
let mut s = "world";
{
let mut changer = || s = "world";
changer();
}
assert_eq!(s, "world");
Если вы привыкли к таким языкам, как JavaScript или Lua, то можете удивиться сложности замыканий в Rust по сравнению с тем, насколько они просты в упомянутых языках. Это необходимая плата за обещание Rust не делать никаких выделений памяти тайком. В JavaScript эквивалент mutate(function() {s = "hello";}) всегда приводит к динамически выделяемому замыканию (со всеми понятными последствиями).
Иногда вы не хотите, чтобы замыкание заимствовало эти переменные, а наоборот, перемещало их.
let name = "dolly".to_string();
let age = 42;
let c = move || {
println!("name {} age {}", name,age);
};
c();
println!("name {}",name);
И ошибка на последнем println: "use of moved value: name".
Поэтому одно из решений здесь — если мы действительно хотим сохранить имя — это переместить клонированную копию в замыкание:
let cname = name.to_string();
let c = move || {
println!("name {} age {}",cname,age);
};
Зачем нужны перемещаемые замыкания? Потому что нам может понадобиться вызвать их в точке, где исходный контекст больше не существует. Классический случай — создание потока. Перемещенное замыкание не заимствуется, поэтому не имеет времени жизни.
Одно из основных применений замыканий — это методы итераторов. Вспомните итератор диапазона, который мы определили для перехода по диапазону чисел с плавающей точкой. С этим (или любым другим итератором) легко работать с помощью замыканий:
let sine: Vec<f64> = range(0.0,1.0,0.1).map(|x| x.sin()).collect();
map не определяется на векторах (хотя достаточно легко создать трейт, который это делает), потому что тогда каждая map будет создавать новый вектор.
Таким образом, у нас есть выбор. В этой сумме не создается никаких временных объектов:
let sum: f64 = range(0.0,1.0,0.1).map(|x| x.sin()).sum();
Это будет (на самом деле) так же быстро, как и запись в явном цикле! Такая гарантия производительности была бы невозможна, если бы замыкания Rust были такими же, как замыкания JavaScript.
Вот обратная сторона сложности Rust: как видно, этот подход имеет оправданные преимущества перед подходом «дешево и сердито», свойственным JavaScript.
filter — еще один полезный метод итератора — он пропускает только те значения, которые соответствуют условию:
let tuples = [(10,"ten"),(20,"twenty"),(30,"thirty"),(40,"forty")];
let iter = tuples.iter().filter(|t| t.0 > 20).map(|t| t.1);
for name in iter {
println!("{} ", name);
}
// thirty
// forty
Три вида итераторов
Прочитав про замыкания, теперь мы готовы вернуться к итераторам и посмотреть на них иначе. Как было указано выше, замыкания позволяют конструировать итераторы более эффективно.
Три вида итераторов соответствуют (опять же) трем основным типам аргументов.
Предположим, у нас есть вектор значений String. Ниже приводим все три типа итераторов: в явном виде, а затем в неявном, вместе с фактическим типом (возвращаемым итератором):
for s in vec.iter() {...} // &String
for s in vec.iter_mut() {...} // &mut String
for s in vec.into_iter() {...} // String
// implicit!
for s in &vec {...} // &String
for s in &mut vec {...} // &mut String
for s in vec {...} // String
Лично я предпочитаю явную форму, но важно понимать все формы и их последствия.
into_iter потребляет вектор и извлекает его строки, после чего вектор уже недоступен — он был перемещен. Это определенная загвоздка для питонистов, привыкших говорить for s in vec.
Так что неявная форма для s в &vec — это обычно то, что вам нужно, так же как &T — это хороший стандарт при передаче аргументов функциям.
Важно понимать, как работают эти три вида, потому что Rust в значительной степени полагается на вычитание типов — вы не часто увидите явные типы в аргументах замыкания. И это хорошо, потому что получалось бы достаточно многословно, если бы все эти типы каждый раз были явно указаны. Однако цена такого компактного кода в том, что вы должны лучше понимать, что на самом деле представляют собой неявные типы.
map принимает любое значение, которое возвращает итератор, и преобразует его во что-то другое, а filter принимает ссылку на это значение. В данном случае мы используем iter, поэтому тип элемента итератора — &String. Обратите внимание, что filter получает ссылку на этот тип.
for n in vec.iter().map(|x: &String| x.len()) {...} // n is usize
....
}
for s in vec.iter().filter(|x: &&String| x.len() > 2) { // s is &String
...
}
При вызове методов Rust автоматически сделает deref, поэтому проблема не так очевидна. Но |x: &&String| x == "one"| не будет работать, потому что операторы более строги к соответствию типов.
В этом случае rustc будет жаловаться, что нет такого оператора, который сравнивает &&String и &str. Поэтому вам нужно явное обращение, чтобы превратить &&String в &String, который действительно совпадает.
for s in vec.iter().filter(|x: &&String| *x == "one") {...}
// same as implicit form:
for s in vec.iter().filter(|x| *x == "one") {...}
Если не указывать явный тип, можно изменить аргумент так, чтобы тип s теперь был &String:
for s in vec.iter().filter(|&x| x == "one")
И обычно в реальной жизни именно так это и записывается.
Продолжение следует…

Сообщить об опечатке
Текст, который будет отправлен нашим редакторам: