Skip to content

Instantly share code, notes, and snippets.

@rpjohnst
Created April 15, 2019 22:40
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save rpjohnst/a68de4c52d9b0b0f6ddf54ca293cceee to your computer and use it in GitHub Desktop.
Save rpjohnst/a68de4c52d9b0b0f6ddf54ca293cceee to your computer and use it in GitHub Desktop.

Async for loops by composing transformations

Rust has three foundational traits: Try for fallible computation, Iterator for resumable computation, and Future for asynchronous computation. These traits are consumed by language features ?, for, and await, that call trait methods under the hood. Further, programs can (or will be able to, or may be able to) implement these traits with language features try, generators, and async.

Today we can compose Try and Iterator (e.g. impl Iterator<Item = Option<T>>), or Try and Future (e.g. impl Future<Item = Result<T, E>>). We would also like to compose Iterator and Future, as described by @withoutboats here:

Evaluated immediately Evaluated asynchronously
Return once fn() -> T async fn() -> T (Future)
Yield many fn() yield T (Iterator) async fn() yield T (Stream)

I would like to suggest a framework along these lines for thinking about these traits and their associated built-in syntax.

Streams

Previous work has represented this composition with a new trait called Stream, which manually combines the types of Iterator::next and Future::poll into Stream::poll_next:

trait Stream {
    type Item;
    fn poll_next(self: Pin<&mut Self>, waker: &Waker) -> Poll<Option<Self::Item>>;
}

This is more complicated than the Try examples above, which simply insert some impl Try into the existing Iterator or Future trait via an associated Item type. Trying to do the same thing here is wrong- an impl Iterator<Item = impl Future<Item = T>> allows the consumer to obtain a new future before awaiting the first.

Something deeper is going on here. A fallible function doesn't require any changes to its signature beyond wrapping its return type, but both generators and asynchronous functions undergo transformations into state machines. In the case of Futures, we have a name for this transformation- async blocks and async fns. The Stream trait could conceptually be written like this instead:

trait Stream {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
}

The only difference between this and the original Stream trait is that it splits out the Future part into a separate object with a reference back to the stream. And in exchange, we can now imagine writing this trait simply as async Iterator, much like the generic const fn RFC writes const SomeTrait.

Aside: other combinations

We can apply the same logic to the Try examples if we consider a hypothetical try fn that wraps its return type:

  • An iterator that returns Results could implement the trait try Iterator, with the method try fn next(&mut self) -> Option<Self::Item> desguaring to fn next(&mut self) -> Result<Option<Self::Item>, E>. This is different from what we normally use today, but it's probably a better signature for eventually integrating ? with generators- an error terminates the iteration, rather than simply being an iterator-of-results.
  • A future that returns a Result could implement the trait try Future, with the desugared method fn poll(self: Pin<&mut Self>, waker: &Waker) -> Result<Poll<Self::Item>, E>. This is actually identical (modulo naming and pinning) to the futures-0.1 Future trait!

It's important to note that there are two ways to compose these traits- until now, we've gotten by with the simpler version that just layers them, but now we want the deeper version that actually applies one's transformation to the other. We want this for streams now, but we may also want it for fallible generators later.

Pattern syntax

Pattern matching takes the syntax that expressions use to construct values, and uses it in reverse to destructure values. For example:

  • A tuple constructed with let tuple = (x, y) is destructured with let (x, y) = tuple.
  • A struct constructed with let s = MyStruct { field: x } is destructured with let MyStruct { field: x } = s.
  • A reference constructed with let r = &x is destructured with let &x = r. (Not to be confused with ref, which is not a destructuring syntax but a binding mode that, like mut, applies only to identifiers and not sub-patterns.)
  • In unstable Rust, a box constructed with let b = box x is destructured with let box x = b.
  • An enum constructed with let e = MyEnum::Variant(x) is destructured with if let MyEnum::Variant(x) = e.

We can look at the language features for Try and Future to see what syntax we might use for pattern matching. We have expression-side destructuring syntax already- ? to unwrap an impl Try, and await! to unwrap an impl Future. But those don't make sense in pattern position any more than field access (s.field) or dereferencing (*r). Instead we should look at the expression-side construction syntax:

  • A result is constructed with let r = try { x } (assuming Ok-wrapping and appropriate type inference), so it ought to be destructured with let try { x } = r. This would propagate any error value in r.
  • A future is constructed with let f = async { x }, so it ought to be destructured with let async { x } = f. This would drive f to completion.

I left Iterator for last because, in a sense, it already has pattern matching in the form of for loops. That is:

  • An iterator is.. iterated? with for x in i. (Perhaps generators ought be declared and constructed using the for keyword? :P)

The Iterator equivalent to field access, dereferencing, or ? is prooobably the trait's collection of methods like collect or nth. I'm less satisfied with this analogy so I'm leaving it out of the bullet points.

Composing pattern syntax

So we arrive at the original question- how do we actually use streams and other combinations of these traits? The simple chaining case is straightforward enough:

  • We often consume impl Iterator<Item = impl Try>s using a for loop that contains an elem? expression.
    • We might instead write that as for try { x } in iter, if we had the syntax described above.
  • We might consume fallible futures as await!(f)?, or however else that bikeshed gets painted.
    • We might instead write let try { x } = await f or let async { try { x } } = f.
  • If anyone did have an iterator of futures, they could await them in a loop.
    • Or, they might write for async { x } in iter.

But what about the deeper compositions? We could go the way of the Stream trait and bake the two syntaxes together, specially for each combination. @withoutboats' post for await loops (Part I) explores some variations on that theme, and even includes a triple combination that iterates, awaits, and tries.

Given this framework of applying transformations to traits, we might consider using the same keywords here, depending on their role as an expression or pattern. The syntax above merely applies one operation after the other, but what we want here is, in a sense, to apply one operation to the other. The futures-async-await crate even made this fairly literal with the syntax #[async] for elem in stream!

  • try for x in iter might work on fallible generators, terminating iteration and returning an error if it occurs.
  • await { f }? combines expression syntax with expression syntax, while let try async x = f combines pattern syntax with pattern syntax.
  • async for x in stream combines pattern syntax with pattern syntax, and suggests applying the async transformation to for's associated trait, Iterator.
  • try async for x in stream goes one step further, and suggests applying the try transformation to async Iterator.

Some potential downsides:

  • @withoutboats notes that this sort of approach could be confused with async { for elem in stream }. Hopefully for's association with pattern matching helps counteract this.
  • @ManishEarth points out that this can lead to long chains of keywords. I'm not sure what to do about this, because the alternatives are a) more sigils? not much better or b) more inference? people seem to dislike this.

Aside: more inference?

As I suggested in great detail in Explicit future construction, implicit await, we might sidestep a lot of this confusion and verbosity by automatically doing this sort of unwrapping when in the right context. In general, we could await calls to async fns when made from other async fns, unwrap results of try fns when called from other try fns, and so forth.

This makes composition a bit easier. Iterating some T: async Iterator from within an async fn wouldn't need new syntax at all- it could just reuse for x in iter. Iterating some T: try Iterator from within a try fn wouldn't need to come up with a pattern-side syntax for ?- it could also just reuse for x in iter. The combination, T: try async Iterator, would also work.

Going back to the generic const fn RFC, this might even work for generic functions. Combinators like unwrap_or_else or and_then could start accepting async closures and then automatically become async themselves, enabling more code to be ported directly between the async and sync worlds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment