Замыкания в Rust

c6cc01fbecaba8b13d24b32627b4304b

Замыкания в 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 знания помогают плохо (что особенно досадно и внезапно). В общем, краткая и емкая статья по теме замыканий долго не получалась, норовя обернуться многостраничным (а то и многостатейным) монстром.

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


  • Использование значения non-Copy переменной
  • Использование значения Copy-переменной
  • Использование ссылки на Copy или non-Copy
  • move + использование значения Copy-переменной
  • move + использование ссылки на 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 {
    ...
    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 можно использовать только один раз
  • При таком способе захвата замыкание можно возвращать из функций и передавать в потоки

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

    // 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: FnMut {
    ...
    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: FnOnce {
    ...
    extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}

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

Итого:


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

Получается то же, что и для случая использования Copy-переменной по значению, между Copy и non-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 внешней переменной мы объявляем изменяемость ее копии в теле замыкания. Фу так делать. В «эквиваленте» все работает без mut:

    // 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 переменные по значению

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


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

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

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

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

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 есть три встроенных типа, которые соответствуют замыканиям, иерархия такова: FnFnMutFnOnce. Т.е. если функция требует FnOnce, вместо него можно подать Fn или FnMut и так далее.

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

    let mut call_count = 0;

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

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

Классика:

fn call_sum_way1 i32>(mut sum: F) {

Фастфуд:

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

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

fn call_sum_way3(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(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 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 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) {
    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 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: F) -> JoinHandle 
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static, 

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

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` cannot be sent between threads safely
   --> src/main.rs:13:18
    |
13  |       let handle = thread::spawn(|| {
    |  __________________^^^^^^^^^^^^^_-
    | |                  |
    | |                  `Rc` cannot be sent between threads safely

Значения, на которые ссылаются замыкания, передаваемые в потоки, должны жить долго, гарантированно не меньше, чем поток — т.е. то самое '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, также не являются «замыканиями» в приведенном выше смысле, так у них в теле нет ссылок на внешние переменные.

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

© Habrahabr.ru