В нашей последовательной серии материалов мы рассмотрим базовые основы новомодного языка 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")
И обычно в реальной жизни именно так это и записывается.
Продолжение следует…
Прокси (proxy), или прокси-сервер — это программа-посредник, которая обеспечивает соединение между пользователем и интернет-ресурсом. Принцип…
Согласитесь, было бы неплохо соединить в одно сайт и приложение для смартфона. Если вы еще…
Повсеместное распространение смартфонов привело к огромному спросу на мобильные игры и приложения. Миллиарды пользователей гаджетов…
В перечне популярных чат-ботов с искусственным интеллектом Google Bard (Gemini) еще не пользуется такой популярностью…
Скрипт (англ. — сценарий), — это небольшая программа, как правило, для веб-интерфейса, выполняющая определенную задачу.…
Дедлайн (от англ. deadline — «крайний срок») — это конечная дата стачи проекта или задачи…