Фrustрастция (Inferring closure signature)

Я начал делать свой executor/reactor и застрял:

Первая попытка:

use std::future::Future;

struct Runtime;

fn start_with_runtime<C, F>(closure: C)
where
    C: for<'a> FnOnce(&'a Runtime) -> F,
    F: Future
{
    let rt = Runtime;
    let _future = closure(&rt);
    // block_on(future)
}

async fn async_main(_rt: &Runtime) {}

fn main() {
    start_with_runtime(|rt| { async_main(rt) });
}

Идея проста, executor/reactor запускаются с помощью функции start_with_runtime(). Однако здесь я хочу попробовать идею когда API для работы с асинхронным кодом передается в качестве параметра, поэтому start_with_runtime() создает runtime на стеке и на вход просит функцию/замыкание которое получит ссылку на этот runtime. Запускаем:

error: lifetime may not live long enough
  --> src/main.rs:17:31
   |
17 |     start_with_runtime(|rt| { async_main(rt) });
   |                         ---   ^^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'2`
   |                         | |
   |                         | return type of closure is impl std::future::Future
   |                         has type `&'1 Runtime`

(playround)

Что за ерунда? Рассахариваем async просто для упрощения:

struct Runtime;

trait Shmutura {}
struct ShmuturaImpl<'a> {
   rt: &'a Runtime
}
impl<'a> Shmutura for ShmuturaImpl<'a> {
}

fn start_with_runtime<'b, C, F>(closure: C)
where
    C: for<'a> FnOnce(&'a Runtime) -> F,
    F: Shmutura + 'b
{
    let rt = Runtime;
    let _future = closure(&rt);
    // block_on(future)
}

fn desugared_async<'a>(rt: &'a Runtime) -> impl Shmutura + 'a {
  ShmuturaImpl { rt }
}

fn main() {
    start_with_runtime(|rt| { desugared_async(rt) });
}

Ошибка не изменилась, но теперь я вижу что с async и Future это вообще никак не связано.

Итак, я ожидаю что замыкание, которое мы передаем в start_with_runtime() имеет вид |&'a Runtime| -> impl Future + 'a. Однако несколько часов спустя выясняется что это не так. Я нашел такое обсуждение:

https://github.com/rust-lang/rust/issues/58052 :

fn main() {
    let f = |x: &i32| x;
    let i = &3;
    let j = f(i);
}
error: lifetime may not live long enough
 --> src/main.rs:2:23
  |
2 |     let f = |x: &i32| x;
  |                 -   - ^ returning this value requires that `'1` must outlive `'2`
  |                 |   |
  |                 |   return type of closure is &'2 i32
  |                 let's call the lifetime of this reference `'1`

Для замыканий придумали простой синтаксис, я в своем коде не указывал ни тип параметров, ни тип результата в надежде что компилятор сам выведет все типы. Видимо в этом случае он делает что-то такое что трудно от него ожидать, что-то вроде |&'a Runtime| -> impl Future + 'b и тикет #58052 про то что с этим что-то не так.

Начинаешь гулять по тикетам (#56537, #55526 и дальше по ссылкам) чтобы понять как что работает и попадаешь в какую-то кроличью нору, у которой сразу несколько измерений.

Измерение первое: Выглядит это таким образом что команда Rust находит какой-то случай, который компилируется с ошибкой и тогда они начинают обсуждать правильно это или нет. Рассуждения там такие, ну наверное должно компилироваться, почему бы и нет, давайте я поправлю. Следующее сообщение, “я поправил но поломалось в других местах, давайте оставим как есть и потом что-нибудь придумаем”. С одной стороны выглядит так что компетентные люди решают проблемы языка по мере возможностей, с другой стороны какой-то спецификации у языка нет. Таким образом скорей всего нет какой-то возможности сделать вторую реализацию компилятора Rust, потому что как и что должно компилироваться решается по ходу дела.

Измерение второе: чтобы как-то понимать что можно было писать, а что нет. С одной стороны вывод типов и lifetime elision нужны для того чтобы все как бы работало само собой, сложность скрывается и вроде писать код становится концептуально проще. Однако когда все это ломается, приходится со всем этим разбираться чтобы получить понимаю что вообще происходит. Учитывая что нет какого-то стандарта или спецификации, трудно понять где вообще можно искать.

Ладно, вытираем слезки и пытаемся как-то исправить проблему. В кроличьей норке я узнал что сигнатура у замыканий выводится оглядываясь на требуемую сигнатуру, так что может быть можно как-то указать правильную сигнатуру здесь:

fn start_with_runtime<'b, C, F>(closure: C)
where
    C: for<'a> FnOnce(&'a Runtime) -> F,
    F: Shmutura + 'b

В реализации выше явно указано гонево в виде'b, но это от безысходности, на этом месте должно быть 'a от for<'a> что не компилируется или что что-то вроде такого:

fn start_with_runtime<C>(closure: C)
where
    C: for<'a> FnOnce(&'a Runtime) -> (impl Shmutura +'a),

Что тоже не компилируется:

error[E0562]: `impl Trait` not allowed outside of function and inherent method return types
  --> src/main.rs:12:40
   |
12 |     C: for<'a> FnOnce(&'a Runtime) -> (impl Shmutura + 'a),
   |                                        ^^^^^^^^^^^^^^^^^^

Нельзя использовать impl Trait для возвращаемого параметра FnOnce. Если же я в своих функциях укажу ShmuturaImpl как возвращаемый тип, то все компилируется:

(ссылку на playground не могу поставить из-за лимита)

struct Runtime;

trait Shmutura {}
struct ShmuturaImpl<'a> {
    rt: &'a Runtime,
}
impl<'a> Shmutura for ShmuturaImpl<'a> {}

fn start_with_runtime<C>(closure: C)
where
    C: for<'a> FnOnce(&'a Runtime) -> ShmuturaImpl<'a>,
{
    let rt = Runtime;
    let _future = closure(&rt);
    // block_on(future)
}

//fn desugared_async<'a>(rt: &'a Runtime) -> impl Shmutura + 'a {
fn desugared_async<'a>(rt: &'a Runtime) -> ShmuturaImpl<'a>  {
    ShmuturaImpl { rt }
}

fn associate<C>(closure: C) -> C
where
    C: for<'a> FnOnce(&'a Runtime) -> ShmuturaImpl<'a>
{
    return closure;
}

fn main() {
    start_with_runtime(associate(|rt| desugared_async(rt)));
}

Значит гипотеза о том чтобы можно было указать правильный expected signature работает и lifetime у замыкания в этом случае корректно выводится. В случае с шмутурами это может работать, но в случае с async и Future мне не известен тип Future, так что надо думать дальше.

Проверим кстати что HRTB нельзя заменить на lifetime привязанный к start_with_runtime:

fn start_with_runtime<'b>(closure: impl FnOnce(&'b Runtime) -> ShmuturaImpl<'b>)
{
    let rt = Runtime;
    let _future = closure(&rt);
    // block_on(future)
}

Не собирается:

 Compiling playground v0.0.1 (/playground)
error[E0597]: `rt` does not live long enough
  --> src/main.rs:14:27
   |
11 | fn start_with_runtime<'b>(closure: impl FnOnce(&'b Runtime) -> ShmuturaImpl<'b>)
   |                       -- lifetime `'b` defined here
...
14 |     let _future = closure(&rt);
   |                   --------^^^-
   |                   |       |
   |                   |       borrowed value does not live long enough
   |                   argument requires that `rt` is borrowed for `'b`
15 |     // block_on(future)
16 | }
   | - `rt` dropped here while still borrowed

Все, я в тупике.

2 лайка

Да, занятная история. Но по-моему тут вся проблема в том, что impl Trait висит в каком-то недоделаном состоянии с момента его введения.

Можно заменить замыкание на функцию:

trait Future {}

impl<T> Future for T {}

// Unwind async async_main(&usize)
// https://rust-lang.github.io/async-book/03_async_await/01_chapter.html#async-lifetimes
fn async_main<'a>(rt: &'a usize) -> impl Future + 'a { }

fn start_with_runtime<F>(closure: for<'a> fn(&'a usize) -> F) 
where F:Future // + 'a //how to bound lifetime?
{
    let rt = 0;
    let future = closure(&rt);    
}

fn main() {
    start_with_runtime(async_main);
}

Здесь будет тоже тупик:

   Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
  --> src/main.rs:17:24
   |
7  | fn async_main<'a>(rt: &'a usize) -> impl Future + 'a { }
   |                                     ---------------- the found opaque type
...
17 |     start_with_runtime(async_main);
   |                        ^^^^^^^^^^ one type is more general than the other
   |
   = note: expected fn pointer `for<'a> fn(&'a usize) -> impl Future`
              found fn pointer `for<'a> fn(&'a usize) -> impl Future`

Такие конструкции тоже запрещены:

fn start_with_runtime(cl: for<'a> fn(&'a usize) -> impl Future + 'a)

Сейчас просто нет способа связать время жизни обобщенного параметра <F:Future> с HRTB временем 'a.

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

trait BorrowingFn<'a> {
    type Fut: std::future::Future<Output = Something> + 'a;
    fn call(self, arg: &'a Runtime) -> Self::Fut;
}

impl<'a, Fu: 'a, F> BorrowingFn<'a> for F
where
    F: FnOnce(&'a Runtime) -> Fu,
    Fu: std::future::Future<Output = Something> + 'a,
{
    type Fut = Fu;
    fn call(self, rt: &'a Runtime) -> Fu {
        self(rt)
    }
}

после чего требуемое условие записывается как for<'a> BorrowingFn<'a> (playground).

Но внимательный читатель может заметить, что я вызываю start_with_runtime не с замыканием, а напрямую с async-функцией. Заставить компилятор выводить правильный тип для замыкания в этом контексте у меня пока не получилось (rust-lang/rust#70263).

2 лайка

Спасибо! Это как раз такой новый прием для меня над которым надо хорошенько подумать и принять на вооружение. Я сейчас думаю что можно смириться с тем что это не работает c замыканиями для этой задачи.

Я думал что если ничего не выйдет откачу к варианту с методом (playground)

impl Runtime {
    fn start_with_runtime<'b, C, F>(&'b self, closure: C)
    where
        C: FnOnce(&'b Runtime) -> F,
        F: Future + 'b,
    {
        let rt = Runtime;
        let _future = closure(&self);
    }
}

fn main() {
    let rt = Runtime;
    rt.start_with_runtime(|rt| async_main(rt));
}

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

BorrowingFn довольно прикольная идея, по моему я вынес для себя несколько вещей о которых не знал. Например я как-то не знал что в определении трейта можно накладывать ограничения ассоциированные типы (просто все примеры обычно про Iterator или еще что-то такое где просто type Output;).

Еще довольно круто что можно сделать вот так impl<..., F> MyTraitFn for F where F: FnOnce(..), т.е. такой MyTraitFn теперь можно использовать как спецификацию вместо FnOnce в генериках.

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

Сколько еще таких чудных приемчиков скрывает раст.

@ tanriol, не хотите опубликовать свой ответ на stackoverflow https://stackoverflow.com/questions/63517250? Я бы тогда засчитал его. Все предложения которые я получил там связаны предполагают использование кучи, что мне не очень нравится.

Ну, не мало. Вот интересная статья - Solving the Generalized Streaming Iterator Problem without GATs. Я уже несколько раз давал на нее ссылку на этом форуме. Не помешает повторить. К сожалению, актуальности она пока не теряет.
Там в первом пункте как раз разбирается прием, похожий на тот который использовал @tanriol. Я тоже пытался его применить, но что-то у меня там не срослось. :frowning_face: