Основы 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")

И обычно в реальной жизни именно так это и записывается.

Продолжение следует…

Останні статті

Обучение Power BI – какие онлайн курсы аналитики выбрать

Сегодня мы поговорим о том, как выбрать лучшие курсы Power BI в Украине, особенно для…

13.01.2024

Work.ua назвал самые конкурентные вакансии в IТ за 2023 год

В 2023 году во всех крупнейших регионах конкуренция за вакансию выросла на 5–12%. Не исключением…

08.12.2023

Украинская IT-рекрутерка создала бесплатный трекер поиска работы

Unicorn Hunter/Talent Manager Лина Калиш создала бесплатный трекер поиска работы в Notion, систематизирующий все этапы…

07.12.2023

Mate academy отправит работников в 10-дневный оплачиваемый отпуск

Edtech-стартап Mate academy принял решение отправить своих работников в десятидневный отпуск – с 25 декабря…

07.12.2023

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

Служба безопасности Украины задержала в Киеве 46-летнего программиста, который за деньги устанавливал шпионские программы и…

07.12.2023

Как вырасти до сеньйора? Девелопер создал популярную подборку на Github

IT-специалист Джордан Катлер создал и выложил на Github подборку разнообразных ресурсов, которые помогут достичь уровня…

07.12.2023