Async Builders
— 2019-09-21
- refresher: builder pattern
- await and the borrow checker
- async finalizers
- addendum: future ergonomic improvements
- extra: guarding against manual re-polling
- extra: javascript promises
- 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 Option
s 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!