Замыкания в Rust

—

от автора

Трудности замыкания

Замыкания в Rust — это функции, которые используют переменные в своей области видимости, пример:

    let mut call_count = 0;      let mut sum = | x, y | {         call_count += 1;         x + y     };      dbg!(sum(2, 2));     dbg!(sum(3, 3));     dbg!(sum(4, 4));     dbg!(call_count);

Тема непростая, и не только для Rust. Казалось бы, что может быть проще JavaScript? Но ведь и там: What is a closure in JavaScript and why most people have the wrong idea?.

В другой статье рассматривается вот такой код:

let fns =[]  for(var i = 0; i < 5; i++) {     var c = i * 2;     fns.push( _ => console.log(c)) }  fns.forEach( f => f() )

Как бы, что будет?? Код, с точки зрения автора, проблематичен и проблема названа «The For Loop little problem».

Схожий по неочевидности сценарий для Go (подробное обсуждение здесь):

fns := make([]func(),0)  for i := 0; i < 5; i++ {     f := func(){fmt.Println(i)}     fns = append(fns, f) }  for _, f := range(fns){     f() }

Для Rust все это многократно усложняется заимствованиями, временами жизни, контролем за изменяемостью (mut), дополнительным способом определения замыканий через move и необходимостью в некоторых случаях использовать Box для возврата замыканий. Количество вариантов огромно, ранее полученные по Rust знания помогают плохо (что особенно досадно и внезапно). В общем, краткая и емкая статья по теме замыканий долго не получалась, норовя обернуться многостраничным (а то и многостатейным) монстром.

В конечном итоге вроде бы нашел подход, он заключается в следующем. Компиляторы для замыканий делают много неявной работы, если эту работу смоделировать и показать в условно эквивалентном коде, все станет намного яснее. Наверное. Попробуем.

Базовые случаи использования внешних переменных в замыканиях Rust

  • Использование значения non-Copy переменной
  • Использование значения Copy-переменной
  • Использование ссылки на Copy или non-Copy
  • move + использование значения Copy-переменной
  • move + использование ссылки на Copy или non-Copy

В реальности, конечно, могут встретиться разнообразные комбинации указанных сценариев (добро пожаловать в ад), но это уже оставляю читателю на самостоятельный разбор.

Использование значения non-Copy переменной

Пример:

    {         let s = String::from("hello");          let closure = || {             dbg!(s);         };          // dbg!(s); // error[E0382]: use of moved value: `s`          closure();         // closure(); // error[E0382]: use of moved value: `closure`     }

Такое замыкание безвозвратно «кушает» переменную s, ее больше нельзя использовать, а само замыкание нельзя вызвать более одного раза.

Замыкание такого типа моделируется структурой Closure {s: String,} которая реализует FnOnce:

trait FnOnce {     fn call_once(self); }

Наш FnOnce является упрощенной моделью std::ops::FnOnce, через которую замыкание используется в реальности:

pub trait FnOnce<Args> {     ...     extern "rust-call" fn call_once(self, args: Args) -> Self::Output;     ...

Тут надо заметить, что такое определение не есть «настоящий сварщик», это как бы intrinsic, который особым образом обрабатывается компилятором.

Данные условно-эквивалентного замыкания инициализируются следующим образом:

    let closure = Closure{s: s};

Ясно, что дальнейшее использование s исключено, так как Copy для строк не реализован и происходит передача владения значением в поле Closure.s.

Вызов происходит так:

    closure.call_once(1);         // ClosureFnOnce::call_once(closure, 1);

Во второй строке показан эквивалент вызова и по нему видно, что владение переменной closure переходит в метод call_once(), и closure далее использовать нельзя.

Такое замыкание без проблем можно вернуть из функции или передать в поток, тип возвращаемого значения описывается как impl std::ops::FnOnce() -> ():

fn new_closure() -> impl std::ops::FnOnce() -> () {     let s = String::from("new_closure");     || {         dbg!(s);     } }  fn new_equivalent_closure() -> Closure {     let s = String::from("new_equivalent_closure");     Closure{s: s} }

Итого:

  • Захваченные non-Copy переменные далее использовать вне тела замыкания нельзя
  • Замыкание FnOnce можно использовать только один раз
  • При таком способе захвата замыкание можно возвращать из функций и передавать в потоки

Использование значения Copy-переменной

Теперь рассмотрим такой пример:

    // Capturing a variable whose type implements Copy     {         let mut p = Point{x: 10, y: 20};         {             let closure = || {                 dbg!(p);             };              // p.x = 11; // error[E0506]: cannot assign to `p.x` because it is borrowed             dbg!(p);              closure();              closure();         }         p.x = 11;         dbg!(p);     }

В качестве типа захватываемой переменной будем использовать:

#[derive(Copy, Clone, Debug)] struct Point {     x: i32,     y: i32, }

Для такого случая компилятор «приготовит» Fn:

pub trait Fn<Args>: FnMut<Args> {     ...     extern "rust-call" fn call(&self, args: Args) -> Self::Output; }

Обратим внимание, что теперь self передается по ссылке, а определение «эквивалента» содержит ссылку на переменную и пестрит временами жизни:

struct Closure<'a> {     p: &'a Point, }  impl<'a> Fn for Closure<'a> {     fn call(&self) {         dbg!(*self.p);     } }

Иными словами, использование в замыкании переменной такого типа по значению компилируется в использование по ссылке.

Это кардинально меняет свойства замыкания. Теперь его можно вызвать несколько раз но нельзя возвратить из функции, так как в Rust нельзя возвращать ссылки на локальные переменные:

fn new_equivalent_closure<'a>() -> Closure<'a> {     let p = Point{x: 10, y: 20};     Closure{p: &p} }

error[E0515]: cannot return value referencing local variable `p`

Кроме того, такой захват переменных блокирует их на чтение, так что менять их в области видимости замыкания нельзя.

Замыкание может менять данные, при этом замыкание будет иметь форму FnMut:

pub trait FnMut<Args>: FnOnce<Args> {     ...     extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output; }

Из «эквивалента» видно, что надо везде тщательно проставить mut, плюс замыкание блокирует переменную на запись, так что читать ее в области видимости замыкания нельзя.

Итого:

  • Захваченные на чтение Copy-переменные нельзя изменять в области видимости замыкания
  • Захваченные на запись Copy-переменные нельзя читать в области видимости замыкания
  • Замыкания Fn/FnMut можно использовать несколько раз
  • При таком способе захвата Fn/FnMut нельзя передавать в потоки и возвращать из функций

Использование ссылки на Copy или non-Copy

Получается то же, что и для случая использования Copy-переменной по значению, между Copy и non-Copy разницы нет.

move + использование значения Copy-переменной

Если требуется возвратить замыкание, которое захватывает локальную Copy-переменную, или передать его в поток, то на помощь придет move. При этом переменная будет скопирована в данные замыкания и использована по значению:

    let mut p = Point{x: 10, y: 20};     {         let mut closure = move || {             p.y += 1;             dbg!(p);         };     }

«Эквивалент» теперь выглядит так:

struct Closure {     p: Point, }  impl FnMut for Closure {     fn call(&mut self) {         self.p.x += 1;         dbg!(self.p);     } }

Никаких ссылок, прекрасно! Теперь замыкание можно возвращать из функций и передавать в потоки:

fn new_closure() -> impl std::ops::FnMut() -> () {     let mut p = Point{x: 300, y: 400};     move || {         p.y += 1;         dbg!(p);     } }  fn new_equivalent_closure() -> Closure {     let p = Point{x: 500, y: 660};     Closure{p: p} }

Изменения данных внутри замыкания никак не сказываются на «захваченной» переменной:

    // move + capturing a variable whose type implements Copy     {         let mut p = Point{x: 10, y: 20};         let mut closure = move || {             p.x += 1;             dbg!(p);         };          dbg!(p); // 10, 20          closure();  // 11, 20         closure();  // 12, 20          dbg!(p); // 10, 20     }

Интересный нюанс — компилятор требует изменяемости p, хотя она не меняется. Здесь налицо костыль в языке — изменяемость копии в теле замыкания приходится декларировать через изменяемость внешней переменной. Фу так делать. В «эквиваленте» все работает без mut p:

    // Equivalent     {         let p = Point{x: 100, y: 200};         let mut closure = Closure{p: p};          dbg!(p);          closure.call();          closure.call();          dbg!(p);     }

Итого:

  • move разрывает связь между замыканием и «захваченной» переменной, переменная используется по значению
  • При таком способе захвата получается замыкание типа Fn/FnMut, его можно возвратить из функций или передать в поток
  • Внутри move-замыканий нельзя использовать non-Copy переменные по значению

Формально, кстати (PS: только согласно приводимому ниже определению, есть и другие), такая конструкция не является замыканием, так как в ней отсутствуют ссылки на переменные, объявленные вне тела этой функции:

Замыкание (англ. closure) в программировании — функция первого класса, в теле которой присутствуют ссылки на переменные, объявленные вне тела этой функции в окружающем коде и не являющиеся её параметрами. Говоря другим языком, замыкание — функция, которая ссылается на свободные переменные в своей области видимости.

Замыкание (программирование)

move + использование ссылки на Copy или non-Copy

При использовании ссылки на внешнюю переменную эта переменная будет скопирована в данные замыкания и ссылка будет уже на локальную копию.

При помощи этой штуки легко «наступить на грабли»:

fn move_by_x(p: &mut Point, delta: i32) {     p.x += delta; }  fn main() {     let mut p = Point{x: 10, y: 20};     let mut closure = move || {         move_by_x(&mut p, 1);         dbg!(&p);     };     ...

Т.е. передаем в move_by_x() ссылку, компилятор требует изменяемости p (mut p), можно наивно ожидать, что move_by_x() изменит оригинальное значение, но нет?.

Понятно, что вариантом использования по ссылке является вызов метода, у которого получателем (receiver) является &self. Пример:

impl Point {     fn move_by_x(&mut self, delta: i32) {         self.x += delta;     } }  fn main() {     let mut p = Point{x: 10, y: 20};     let mut closure = move || {         p.move_by_x(1);         dbg!(&p);     };     ...

Свойства замыкания такие же, как и для «move + значение», т.е. получается Fn/FnMut, его можно вызывать много раз, возвращать из функций и передавать в потоки.

Но один интересный нюанс таки есть. Обращаясь к non-Copy переменной по ссылке при помощи move можно сделать возвращаемый (или «передаваемый в потоки») Fn/FnMut:

fn new_closure() -> impl FnMut(){     let mut s = String::from("Back");     move || {         s.push_str(" in the");         s.push_str(" U.S.S.R");         dbg!(&s);     }     }  fn main() {        let mut closure = new_closure();      closure();     closure();     closure(); }

Напротив, вариант без move позволяет вернуть для такой переменной только одноразовый FnOnce:

fn new_closure() -> impl FnOnce(){     let mut s = String::from("Back");     || {         s.push_str(" in the");         s.push_str(" U.S.S.R");         dbg!(s);     }     }  fn main() {     let closure = new_closure();      closure();     // closure(); // error[E0382]: use of moved value: `closure` }

Здесь «возращаемость» обеспечивается использованием по значению (dbg!(s)), одновременно это превращает замыкание в FnOnce.

С использованием внешних переменных внутри замыканий разобрались, теперь посмотрим, как замыкания передавать в функции.

Передача замыканий в функции при помощи параметров типа

В Rust есть три встроенных типа, которые соответствуют замыканиям, иерархия такова: Fn -> FnMut -> FnOnce. Т.е. если функция требует FnOnce, вместо него можно подать Fn или FnMut и так далее.

Допустим, у нас есть замыкание:

    let mut call_count = 0;      let sum = |x, y| {         call_count += 1;         x + y     };

Есть ажно три способа принять его при помощи параметров типа.

Классика:

fn call_sum_way1<F: FnMut(i32, i32) -> i32>(mut sum: F) {

Фастфуд:

fn call_sum_way2(mut sum: impl FnMut(i32, i32) -> i32) {

Стильно, модно, молодежно, рекомендовано:

fn call_sum_way3<F>(mut sum: F) where     EXISTS (SELECT FROM Items WITH (NOLOCK) WHERE Name = F.Name AND Type = "FnMut" AND ParamsCount = 2  AND ResultsCount = 1)      AND EXISTS (SELECT FROM Params WHERE FuncName = F.Name AND Idx = 0 and Type = "i32")      AND EXISTS (SELECT FROM Params WHERE FuncName = F.Name AND Idx = 1 and Type = "i32")          AND EXISTS (SELECT FROM Results WHERE FuncName = F.Name AND Idx = 0 and Type = "i32")      

Шутка, вот так на самом деле:

fn call_sum_way3<F>(mut sum: F) where     F: FnMut(i32, i32) -> i32,

Все три способа в песочнице.

Когда замыкания передаются при помощи параметров типа имеет место т.н. «параметрический полиморфизм», т.е. на каждый вариант используемых при вызове параметров типа генерируется мономорфный вариант принимающей функции. Пример:

fn main() {     let sum = |x, y| {         x + y     };     call_sum(sum);      let sum = |x, y| {         x + y + 20     };     call_sum(sum); }  fn call_sum<F: FnMut(i32, i32) -> i32>(mut sum: F) {     sum(2, 2);     sum(3, 3);     sum(4, 4); }   

Каждое замыкание имеет свой собственный неявный тип, так что компилятор сгенерирует два варианта функции call_sum() под каждый из них, несмотря на то, что сигнатуры замыканий идентичны. Чтобы в этом убедиться опять посмотрим в ассемблер, выключив в настройках Symbol Demangling:

_ZN10playground8call_sum17h27c8971da28680efE:     sub rsp, 56     mov dword ptr [rsp + 16], 2     mov dword ptr [rsp + 20], 2     mov esi, dword ptr [rsp + 16]     mov edx, dword ptr [rsp + 20]     lea rdi, [rsp + 8]     call    _ZN10playground4main28_$u7b$$u7b$closure$u7d$$u7d$17h1ec7382e3c1e9c39E     jmp .LBB17_1     ... _ZN10playground8call_sum17h6c6a460f1c9cf47aE:     sub rsp, 56     mov dword ptr [rsp + 16], 2     mov dword ptr [rsp + 20], 2     mov esi, dword ptr [rsp + 16]     mov edx, dword ptr [rsp + 20]     lea rdi, [rsp + 8]     call    _ZN10playground4main28_$u7b$$u7b$closure$u7d$$u7d$17h9220f028b950d2f4E     jmp .LBB18_1     ...    

Передача замыканий при помощи умных указателей

Принимающая функция может быть скомпилирована в одном варианте («динамический полиморфизм» или «полиморфизм подтипов»), для этого нужно передавать замыкание через «кучу»:

fn main() {     let sum = |x, y| {         x + y     };     call_sum(Box::new(sum));      let sum = |x, y| {         x + y + 20     };     call_sum(Box::new(sum)); }  fn call_sum(mut sum: Box<dyn FnMut(i32, i32) -> i32>) {     sum(2, 2);     sum(3, 3);     sum(4, 4); }

  • Данные перемещаются в кучу при помощи Box::new(sum)
  • Таким образом в кучу можно помещать много чего, не только замыкания
  • В описании параметров нужно использовать волшебное слово dyn (динамический же полиморфизм)
  • call_sum() при этом компилируется в одном экземпляре

Кому-то покажется более изящным такой способ определения параметров:

type MyClosure = dyn FnMut(i32, i32) -> i32;  fn call_sum(mut sum: Box<MyClosure>) {     sum(2, 2);     sum(3, 3);     sum(4, 4); }

Возврат замыканий из функций

Из функций и методов замыкание можно возвращать таким образом:

fn main() {     dbg!(get_sum(1)(10, 20));     dbg!(get_sum(2)(10, 20)); }  fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32 {     return move |x, y| {         mult * (x + y)     }; }

Без move тут не получится, так как иначе мы вернем замыкание, которое ссылается на локальную переменную — параметр mult. Так дело не пойдет, опасно, нужно «замести» все переменные в данные замыкания, что и делает move.

Надо заметить, что вернуть замыкание можно только из одного места, например, вот такой пример не компилируется:

fn get_sum2(mult: i32, minus: bool) -> impl FnMut(i32, i32) -> i32 {     if minus {         return move |x, y| {             mult * (x + y)         };     } else {         return move |x, y| {             mult * (x - y)         };             } }

Сообщения компилятора шикарны:

  = note: to return `impl Trait`, all returned values must be of the same type   = note: no two closures, even if identical, have the same type   = help: consider boxing your closure and/or using it as a trait object

Возврат через impl не работает для интерфейсов:

trait Summer {     fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32; }

error[E0562]: `impl Trait` not allowed outside of function and method return types  --> src/lib.rs:2:30   | 2 |     fn get_sum(mult: i32) -> impl FnMut(i32, i32) -> i32;   |                              ^^^^^^^^^^^^^^^^^^^^^^^^^^^

Интересно, почему? Рассмотрим вот такой пример:

fn call_closure() {     get_closure()(); }  fn call_closure2() {     get_closure2()(); }  fn get_closure() -> impl Fn() {     let array: [i32; 50] = [0; 50];     return move || {         dbg!(array);     }; }  fn get_closure2() -> impl Fn() {     let array: [i32; 100] = [0; 100];     return move || {         dbg!(array);     }; }

В этом примере мы возвращаем два замыкания с данными разного размера. Включим для ассемблера Symbol Demangling и посмотрим, что получается:

playground::call_closure:     sub rsp, 200     mov rdi, rsp     call    playground::get_closure     mov rdi, rsp     call    playground::get_closure::{{closure}}     add rsp, 200     ret  playground::call_closure2:     sub rsp, 408     lea rdi, [rsp + 8]     call    playground::get_closure2     lea rdi, [rsp + 8]     call    playground::get_closure2::{{closure}}     add rsp, 408     ret

Вон оно что, данные замыкания возвращается через стек и вызывающая сторона должна подготовить место для этого (sub rsp, ...). Размер данных замыкания известен компилятору только если он имеет возможность «увидеть» что происходит внутри вызываемой функции. В случае, когда замыкание возвращается из интерфейса, «внутрь» не посмотреть, размер данных неизвестен, может возвращаться что угодно, так что в кучу, товарищи:

trait Summer {     fn get_sum(mult: i32) -> Box<dyn FnMut(i32, i32) -> i32>; }

Передача замыканий в потоки

Пример:

use std::thread;  fn main() {     let s = String::from("Hello");      let handle = thread::spawn(|| {         dbg!(s);     });      handle.join().unwrap(); }

В данном случае все просто — мы готовим FnOnce, thread::spawn() принимает именно такой тип, все работает (unwrap() требует отдельного рассмотрения, не в этой статье).

Определенные «тонкости» в процессе передачи замыканий в потоки, конечно, есть. Чтобы в них разобраться, рассмотрим сигнатуру thread::spawn:

pub fn spawn<F, T>(f: F) -> JoinHandle<T>  where     F: FnOnce() -> T,     F: Send + 'static,     T: Send + 'static, 

F: Send + 'static означает, что данные замыкания должны уметь безопасно передаваться между потоками (Send), и все ссылки в данных замыкания (если они есть) должны иметь время жизни static, т.е. ссылаться на локальные переменные внутри замыкания нельзя. Рассмотрим Send и 'static более подробно.

Передача замыканий в потоки::Send

Send это такой маркерный интерфейс типаж, который указывает, что значение может передаваться между потоками. Согласно Книге:

Почти каждый тип Rust является типом Send, но есть некоторые исключения, вроде Rc: он не может быть Send, потому что если вы клонировали значение Rc и попытались передать владение клоном в другой поток, оба потока могут обновить счётчик ссылок одновременно.

Разрешение передачи во владение между потоками с помощью Send

Проверим. Возьмем Box, его цель — просто хранить значение в куче. Работает:

    let p = Box::new(Point { x: 10, y: 20 });      let handle = thread::spawn(|| {         dbg!(p);     });     handle.join().unwrap(); 

Мы сохранили значение в куче и передали его через данные замыкания в поток. Теперь очередь Rc. Его задача — хранить значение в куче и вести счетчик ссылок на него, когда все держатели ссылок выйдут из области видимости, память освобождается:

    let p1 = Rc::new(Point { x: 10, y: 20 });     let p2 = p1.clone();     let p3 = p1.clone();     dbg!(p1);     dbg!(p2);     dbg!(p3);

  • p1, p2 и p3 ссылаются на единственное значение в куче
  • p1, p2 и p3 можно передать в разные функции и там использовать
  • Менять значение за p1, p2 и p3 нельзя.

Rc быстр, но потокоопасен, поэтому замыкание, которое его использует, лишается почетного значка Send:

    let p = Rc::new(Point { x: 10, y: 20 });      let handle = thread::spawn(|| {         dbg!(p);     });      handle.join().unwrap();

error[E0277]: `Rc<Point>` cannot be sent between threads safely    --> src/main.rs:13:18     | 13  |       let handle = thread::spawn(|| {     |  __________________^^^^^^^^^^^^^_-     | |                  |     | |                  `Rc<Point>` cannot be sent between threads safely

Передача замыканий в потоки::’static

Значения, на которые ссылаются замыкания, передаваемые в потоки, должны жить долго, гарантированно не меньше, чем поток — т.е. то самое 'static. Вот такое не пройдет:

use std::thread;  fn main() {     let x = 10;      let handle = thread::spawn(|| {         dbg!(x);     });      handle.join().unwrap(); }

6 |     let handle = thread::spawn(|| {   |                                ^^ may outlive borrowed value `x` 7 |         dbg!(x);   |              - `x` is borrowed here   | note: function requires argument type to outlive `'static`

Тут-то и пригодится move ||:

use std::thread;  fn main() {     let x = 10;      let handle = thread::spawn(move || {         dbg!(x);     });      handle.join().unwrap(); }

В данных замыкания теперь хранится не ссылка, а копия значения, так что требование 'static удовлетворяется — ссылок-то вообще нет. Вроде просто, но есть нюансы. Например, вот так — можно:

use std::thread;  fn main() {     let s = String::from("hello");      let handle = thread::spawn(|| {         dbg!(s);     });      handle.join().unwrap(); }

Отличие тут в том, что String не реализует интерфейс Copy, выражение dbg!(s) «съедает» переменную s по значению, поэтому для компилятора нет нужды в данных замыкания хранить ссылку на значение, можно это значение сразу переместить в данные замыкания — вне замыкания изменить s, в отличие от x, нельзя.

Иными словами, ключевое слово move незаменимо, если замыкание передается в поток (или возвращается из функции) и использует локальное значение типа, который умеет в Copy.

Как-то так, пора заканчивать.

Некоторые размышления

В прекрасном языке программирования будущего я бы кардинально упростил замыкания путем их усложнения. Указывать способ «захвата» и изменяемость следует явно, напрямую использовать переменные из окружающей среды нельзя:

    let x = 10;      // Используем копию x     let captureByValue = {closurex: x} || {         dbg!(closurex)         // dbg!(x) // error[E0425]: cannot find value `x` in this scope     });      // Используем изменяемую копию x     let mutCaptureByValue = {mut closurex: x} || {         // Изменяем локальную копию         closurex = 20;     });          // Используем ссылку на x     let captureByReference = {closurex: &x} || {         ...         // Изменяем оригинальное значение         *closurex += 1;     });

И все, не нужно было бы писать добрую половину этой статьи, а «проблематичные» конструкции, приведенные в начале, в таком синтаксисе мигом потеряли бы всю проблематичность.

Конечно, будут определенные сложности с определением, приведенным ранее:

«Замыкание (англ. closure) в программировании — функция первого класса, в теле которой присутствуют ссылки на переменные, объявленные вне тела этой функции в окружающем коде и не являющиеся её параметрами»

Тут непросто, имеем дело с традициями, корни которых уходят в прошлое тысячелетие. Замечу, что «замыкания» в Rust, определенные с ключевым словом move, также не являются «замыканиями» в приведенном выше смысле, так у них в теле нет ссылок на внешние переменные.

На этом все? по замыканиям.

Далее: Связный список на Rust в стиле С/C++


ссылка на оригинал статьи https://habr.com/ru/articles/588917/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *