Async Builders
— 2019-09-21

  1. refresher: builder pattern
  2. await and the borrow checker
  3. async finalizers
  4. addendum: future ergonomic improvements
  5. extra: guarding against manual re-polling
  6. extra: javascript promises
  7. conclusion

Last month we introduced Surf, an async cross-platform streaming HTTP client for Rust. It was met with a great reception, and people generally seem to be really enjoying it.

A common piece of feedback we've gotten is how much people enjoy the interface, in particular how little code it requires to create HTTP requests.

surf::get("https://example.com").await?;

In this post we'll cover a pattern at the heart of Surf's ergonomics stjepang came up with: the "async finalizer".

Note: it's probably worth setting some expectations at this point. This post is unfortunately not about async destructors. I'm sorry if this is disappointing. Trust me, I want them too.

Refresher: builder pattern

In languages such as JavaScript it's common to pass options objects to provide configuration. You define an object, set some parameters, and often pass it as an optional extra argument to a function.

// default
let app = choo()

// with options
let opts = { hash: true }
let app = choo(opts)

However in Rust this doesn't quite work the same because of how the type system's rules. Instead there are a few different different ways of defining configuration, of which one of the most common variants is the builder patter.

Instead of passing options into a constructor, you create a configuration struct with methods to set options, and one final method that takes the config and outputs the struct you wanted. An example from std is fs::OpenOptions:

use std::fs::OpenOptions;

let file = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .open("foo.txt");

In the case of OpenOptions the open method converts the configuration into a File. The open method is what we refer to as the "finalizer" method.

note: OpenOptions is a bit special because it can be reused. It's exceedingly common for builders to consume the configuration in their finalizer, as we do in Surf.

Await and the borrow checker

In Async Rust the builder pattern that was shown above works perfectly fine. But because of how await works some interesting options open up. In particular we can lean on the property of await that the compiler statically ensures the same future is not awaited twice.

use async_std::future;

#[async_attributes::main]
async fn main() {
    let fut = future::ready(1u8);
    fut.await;
    fut.await; // this is invalid
}
error[E0382]: use of moved value: `fut`
  --> examples/logging.rs:11:9
   |
9  |    let fut = async_std::future::ready(1u8);
   |        --- move occurs because `fut` has type `impl std::future::Future`, which does not implement the `Copy` trait
10 |    fut.await;
   |    --------- value moved here
11 |    fut.await;
   |    ^^^ value used here after move

error: aborting due to previous error

This compiler error occurs because await takes futures by value, not by reference (self vs &self). Which is exactly the property we want for a consuming finalizer.

Async Finalizers

In Surf we started off with a send method as the finalizer, but it never felt quite right. This is because await filled a similar role already, and send was essentially redundant. Once we realized we were swiftly able to remove it, and the resulting API felt a lot nicer.

We still have a few special-purpose finalizers such as recv_json, but we only use those to reduce the amount of instances of await for shorter scripts.

surf::get("https://example.com").send().await?; // old
surf::get("https://example.com").await?;        // new

await is the primary way the Request builder now terminates, and returns a Result<Response>. Because if you think of it: a request really is a fancy configuration option that's submitted to the server to get what you really want: a response.

The way we've implemented this is more or less like this:

impl Future for Request {
    type Output = Result<Response, Exception>;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        /// Construct a future once, and store it internally.
        if self.fut.is_none() {
            self.fut = Some(Box::pin(async move {
                let res = Next::new(&self.middleware)
                    .run(self.req, self.client)
                    .await?;
                Ok(Response::new(res))
            }));
        }

        /// Now that we have an inner future, we can forward all poll calls to
        /// it until it resolves.
        self.fut.as_mut().unwrap().as_mut().poll(cx)
    }
}

This pattern can be implemented in many different ways, but the core of it is to implement the Future trait on the builder itself.

Addendum: Future ergonomic improvements

As was pointed out by nemo157, it's interesting to consider how this would interact with the IntoFuture trait.

futures-preview no longer has this trait, but futures@0.1 used to have it. There's also a mention of it in last year's async/await RFC.

The idea is that if we had a trait IntoFuture, and await is the keyword to call .into_future() and then poll the future to completion. This would allow us to get rid of boxing the future, and then driving it to completion ourselves, which would be more performant and easier to implement.

All together the usage would remain exactly the same, but the definition could be simplified:

// Trait definition.
mod std {
    mod future {
        pub trait IntoFuture {
            type Future: Future<Output = Self::Output>;
            type Output;
            fn into_future(self) -> Self::Future;
        }
    }
}
// Example usage in surf.
//
// We could construct a manual future here instead of `BoxFuture`, but for the
// purposes of the demo we won't.
impl std::future::IntoFuture for Request {
     type Output = Result<Response, Exception>;
     type Future = Pin<Box<dyn Future<Output = Self::Output> + Send>>

     fn into_future(self) -> Self::Future {
         Box::pin(async move {
             let res = Next::new(&self.middleware)
                 .run(self.req, self.client)
                 .await?;
             Ok(Response::new(res))
         })
     }
}

Even though we've cheated a little with the lifetimes of Pin<Box>, it definitely feels like it removes some of the rough edges around async finalizers. I haven't seen IntoFuture being mentioned anywhere in the async/await stabilization report, which leads me to believe that the omission from the async/await MVP stabilization is mostly because it simply hasn't been deemed a priority.

Extra: guarding against manual re-polling

The only caveat in the (simplified) example above is that it doesn't provide a nice error message if manually polled repeated multiple times. In the actual implementation the inner values are Options that we take, which means the builder would panic if polled after completion. But we can do better.

By having debug_assert and keeping an inner flag we can provide a slightly nicer error message for this case. This would look something like this:

fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
  debug_assert!(self.is_resolved == false, "Cannot poll future after being resolved");

  // Construct the future here

  let res = future::ready!(self.fut.as_mut().unwrap().as_mut().poll(cx));
  self.is_resolved = true;
  Poll::Ready(res)
}

Extra: JavaScript Promises

This pattern is so nice, you might wonder why other languages don't have this. In particular JavaScript's Fetch API uses async/await, and yet doesn't have this pattern.

let myRequest = new Request('flowers.jpg', {
  method: 'GET',
  cache: 'default'
});

let res = await fetch(myRequest);

This is because Rust's futures are lazy; they don't do anything until poll or .await is called on them. JavaScript's promises are eager; they start running the moment they're constructed. This means that construction of the promise needs to be separated from construction of the options.

addendum: as was pointed out by tehdog it's actually possible to achieve do this in JavaScript using "thenables". That is: any object that has a then method on its prototype can be awaited as if it were a Promise, but unlike Promises isn't run until awaited. This enables lazy evaluation in JavaScript, much like in Rust.

class Request {
    constructor(url: string) {
        this.url = url
    }
    then() {
        return fetch(this.url)
    }
}

let x = new Request("hello")  // Request is constructed, no I/O happens.
let res = await x             // Request is sent, I/O happens here.

Conclusion

In this post we've more closely examined the "async finalizer" pattern which underpins some of Surf's biggest ergonomic conveniences. We've explained what builders are, how they work, and how we can achieve the same results in async code using the Future trait.

I'm really happy we figured this out, and since we've released Surf we've seen this same pattern being used in some other places too. We're probably not the first people to think of this pattern, but I'm pretty sure neither Stjepan nor I had seen this before when Stjepan proposed we use it. And I figured it'd be worth writing about so more people can find out about this!

In general I feel this it's still early days in the design space around async/await. As time goes on it seems likely we'll discover more patterns like these, and eventually induct them into a well-known set of "best practices". This is my attempt at sharing a neat little pattern we found, in the hope that more people will discover it and be able to pick it up.

As always, thanks for reading! I hope this was helpful!