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


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


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

Комментарии (7)


  1. mayorovp
    12.11.2021 21:15
    +12

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

    Формально следует смотреть только лексические ссылки, то есть использование идентификаторов из внешней области видимости. А приведёт ли это к созданию ссылки или к копированию/перемещению — вопрос реализации. Кстати, как раз замыкания с копированием (т.е. захватом по значению) считаются "более настоящими".


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

    Вы забыли про такую возможность как type alias, которая как раз и даёт возможность "посмотреть" внутрь возвращаемого типа:


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

    Правда, реализовать такой типаж не так-то просто, и если нет желания раскрывать реализацию замыкания вручную — то самое "умное" что можно сделать — это вернуться к Box<dyn>:


    struct Foo;
    impl Summer for Foo {
        type Closure = Box<dyn FnMut(i32, i32) -> i32>;
    
        fn get_sum(mult: i32) -> Self::Closure {
            Box::new(move |x, y| {
                mult * (x + y)
            })
        }
    }

    Но вот в "ночной" ветке доступна фича type_alias_impl_trait:


    #![feature(type_alias_impl_trait)]
    
    struct Foo;
    impl Summer for Foo {
        type Closure = impl FnMut(i32, i32) -> i32;
    
        fn get_sum(mult: i32) -> Self::Closure {
            move |x, y| {
                mult * (x + y)
            }
        }
    }


    1. maxim_ge Автор
      13.11.2021 21:51
      +1

      Вы забыли про такую возможность как type alias

      Здесь не просто type alias, еще нужны associated types, которые требуют отдельного описания. Это в планах.


      Правда, реализовать такой типаж не так-то просто

      Если это возможно, приведите пример?


      1. mayorovp
        13.11.2021 22:19

        Я же их привёл, аж два примера.


        1. maxim_ge Автор
          14.11.2021 18:41

          После слов:


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

          я ожидал, что таки в рамках Stable channel можно предложить сложную реализацию ("замыкания вручную"), которая возвращает замыкание не через кучу. Cобственно именно это и интересует.


          Вы же предложили (для Stable channel) вернуться к Box+dyn. Ну т.е. таки "в кучу, товарищи".


          В общем, смысл Вашего комментария по отношению к стабильной ветке (про что я, собственно, рассказываю) не очень понятен мне.


          К слову, можно ведь и так определить тип, подающий надежды на то, что можно вернуть замыкание:


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

          Но толку-то...


          1. mayorovp
            14.11.2021 19:11

            А, вот вы про что. Ну, никто же не мешает определить самому структуру Closure и реализовать нужный трейт, как вы это сами и показывали. Долго и нудно, но позволит избежать использования кучи в стабильной ветке.


            1. maxim_ge Автор
              14.11.2021 19:30

              Я показывал упрощенные модели оригинальных трейтов замыканий (FnOnce и т.д.), которые тоже не "настоящие" (как мне кажется). "Не настоящие" в том смысле, что они обрабатываются компилятором особым образом. Я считаю, что реализовать их самостоятельно нельзя, компилятор не поймет, ибо это его сугубо "внутреннее дело". А если и поймет, то нельзя будет вернуть, а если можно вернуть, то что-то еще пойдет не так.


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


              1. mayorovp
                14.11.2021 21:21
                +1

                А, ну да, эта штука тоже ещё не стабильна...