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.
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 fn
s. 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
.
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
Result
s could implement the traittry Iterator
, with the methodtry fn next(&mut self) -> Option<Self::Item>
desguaring tofn 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 traittry Future
, with the desugared methodfn poll(self: Pin<&mut Self>, waker: &Waker) -> Result<Poll<Self::Item>, E>
. This is actually identical (modulo naming and pinning) to the futures-0.1Future
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 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 withlet (x, y) = tuple
. - A struct constructed with
let s = MyStruct { field: x }
is destructured withlet MyStruct { field: x } = s
. - A reference constructed with
let r = &x
is destructured withlet &x = r
. (Not to be confused withref
, which is not a destructuring syntax but a binding mode that, likemut
, applies only to identifiers and not sub-patterns.) - In unstable Rust, a box constructed with
let b = box x
is destructured withlet box x = b
. - An enum constructed with
let e = MyEnum::Variant(x)
is destructured withif 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 }
(assumingOk
-wrapping and appropriate type inference), so it ought to be destructured withlet try { x } = r
. This would propagate any error value inr
. - A future is constructed with
let f = async { x }
, so it ought to be destructured withlet async { x } = f
. This would drivef
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 thefor
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.
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 afor
loop that contains anelem?
expression.- We might instead write that as
for try { x } in iter
, if we had the syntax described above.
- We might instead write that as
- We might consume fallible futures as
await!(f)?
, or however else that bikeshed gets painted.- We might instead write
let try { x } = await f
orlet async { try { x } } = f
.
- We might instead write
- If anyone did have an iterator of futures, they could
await
them in a loop.- Or, they might write
for async { x } in iter
.
- Or, they might write
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, whilelet try async x = f
combines pattern syntax with pattern syntax.async for x in stream
combines pattern syntax with pattern syntax, and suggests applying theasync
transformation tofor
's associated trait,Iterator
.try async for x in stream
goes one step further, and suggests applying thetry
transformation toasync Iterator
.
Some potential downsides:
- @withoutboats notes that this sort of approach could be confused with
async { for elem in stream }
. Hopefullyfor
'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.
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 fn
s when made from other async fn
s, unwrap results of try fn
s when called from other try fn
s, 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.