Skip to main content

Parameterized Futures in Rust

Lately I've been programming with Rust quite a bit. In my opinion, the language shows careful, thoughtful design in almost every decision point of its development. The syntax, semantics, standard library, and tooling are all truly top-notch (and a welcome change from my C++ work). I've also used Rust deeply enough to encounter the edges which aren't so smooth. Async code (using the Rust Future trait) can especially present difficult cases at times, like when dealing with more complicated lifetimes and when you want to be able to take parameterized futures (i.e. future generators) as arguments.

What do I mean by parameterized futures? Here's an example:

do_something_contextual(|context| async move {
    // async things using context
}).await

The function do_something_contextual is passed a closure which takes an argument and returns a future (which presumably will use that argument).

Rust Futures

In the design of the Rust Future trait, the interface was kept as simple as possible. This means that a type implementing Future simply needs to provide a way (the poll method) of trying to get the intended output (or at least make progress toward that goal). While this is satisfying in that it "does one thing", it does complicate code that wants to perform more complicated interactions involving futures. In the above example, the |..| and the async move are independent syntaxes which don't have any special interaction. As such, it creates a closure (the |...|) which will immediately return a future (the async [move] {...}).

Because closures and futures are separate concepts, to write a function which takes such a parameterized future (which really is more like a future generator), the generic constraints are a bit cumbersome:

use std::future::Future;
struct Context;

fn do_something_contextual<F, Fut>(generator: F)
  where
    F: Fn(Context) -> Fut,
    Fut: Future
{ ... }

More realistically, do_something_contextual is likely desired to be async itself, the returned future probably should also be Send (many async runtimes are multi-threaded and may end up polling a future on multiple threads), and you often want to use some returned value from the generator, too:

async fn do_something_contextual<F, Fut, R>(generator: F) -> (R, R)
  where
    F: Fn(Context) -> Fut,
    Fut: Future<Output = R> + Send
{
    let first = generator(some_context).await;
    let second = generator(some_context).await;
    (first,second)
}

This gets somewhat onerous and ugly, but it is still manageable. Moreover, you can make a trait (for each number of function arguments since Rust does not yet support variadic type parameters) with a blanket implementation to encapsulate these requirements. Without generic associated types (GATs), this can get a bit hairy with lifetime requirements, but we won't dive into that here.

Closure Captures and Generator Reuse

The problem I encountered in all of this was that I wanted to store and re-use these generator closures (type-erased) multiple times (modestly mocked by the above example calling generator twice), and the closures needed to be able to produce futures with static lifetimes (in part due to the lack of GATs). As you might imagine, to achieve this the closures would need to clone the necessary state (captures) each time they were called and move it into the returned future.

Such an approach works fine with our above definition of do_something_contextual: these details only matter at the generator creation site, not at the API interface. However, I quickly found that the ergonomics of such an interface were truly awful. I was using this extensively, and everything would basically end up like:

let a = ...; // something Clone
let b = ...; // something Clone
do_something_contextual(|context| {
    let a = a.clone();
    let b = b.clone();
    // etc, for each capture
    async move {
        // Use captures and context
    }
}).await

One great convenience of closures is that you don't need to explicitly list all the captures, it "just works" based on the lexical scope bindings. With this API, you end up needing to enumerate the captures anyway. It could really do with improvement.

The trick I found and took advantage of is that generated closures (by the grace of the Rust developers) derive Clone! So we can change our API to:

async fn do_something_contextual<F, Fut, R>(generator: F) -> (R, R)
  where
    F: FnOnce(Context) -> Fut + Clone,
    Fut: Future<Output = R> + Send
{
    let first = (generator.clone())(some_context).await;
    let second = generator(some_context).await;
    (first,second)
}

With this new API, the ergonomics improve significantly. Rather than a caller being required to make a closure that can be called multiple times to generate futures (thus needing to clone the captures manually), the caller simply needs to ensure that all the captures are Clone, and the Rust compiler will ensure that the closure is FnOnce and Clone for us. Thus, the call-site becomes pretty much as minimal as it can get:

let a = ...; // something Clone
let b = ...; // something Clone
do_something_contextual(|context| async move {
    // Use captures and context
}).await

One downside is that error messages may be a little bit less obvious, but to be fair, the error messages resulting from not cloning the captures in the prior (Fn) case would be similar, basically boiling down to an error saying the closure isn't Clone versus an error saying values are moved from an Fn. In both cases documentation can clarify the issue, and I far prefer the latter case using FnOnce and Clone as you can basically have these parameterized futures be just as convenient to write as normal closures!

I've encountered a few other troublesome edge cases of futures (which required larger refactoring or some very careful std::mem::transmute to promise that the lifetimes were in fact correct and sound), but this one ended up being very handily fixed with the language itself. It's very satisfying to discover that the features of a language allow for such ergonomic API improvements, a recurring pattern I've seen in my Rust development!

Comments