For await loops

This was certainly an interesting and very novel idea, but thinking it through I don’t think it works out.

The fundamental difference between const and async here is that const fns are fungible as non-const fns - they have the same type signature, they just can be called in more places and have additional restrictions on their bodies; async fns in contrast have a different type signature from the an otherwise syntactically identical fn. The idea of T: async Trait is very different then from T: const Trait; a type that implements “async Iteratordoes not implement Iterator.

Ultimately I’m very dubious of these far reaching effect system types of changes because there are actually a lot of irregularities and corner cases in what people want to be able to do. I am much more optimistic about the design path we’ve been on for async/await, which focuses on introducing a few syntaxes as sugar for constructing library types without modifying the actual type system, essentially extending the success story we had with for loops to generators and async/await.

6 Likes

FYI, this is also an upcoming feature in C#8 (https://github.com/dotnet/csharplang/issues/43) and its syntax has also been a contentious issue.

In both cases the loop construct contains enough syntax sugar to warrant special async syntax, since Stream<Item = T> is not just an Iterator<Item = Future<Output = T>>. I think any syntax that looks like you’re awaiting stream items would only confuse and mislead.

I don’t think the issue should be lumped together with ? on elements, since let line = line?; is a perfectly cromulent expression that, in my opinion, doesn’t really warrant a special loop syntax, while let line = await!(line); cannot be used to do the same to a stream.

4 Likes

I didn’t get into this in my post, but an important point here is that const is really the absence of an effect, and thus equivalent to non-async, and not the other way around. The analogy thus actually works in the other direction:

Just as const fn can be used where a plain fn is expected, a plain fn can be used where an async fn is expected. Something that expects a T: async Trait could also use a plain T: Trait, with the compiler generating a trivial shim. A type that implements Iterator does implement async Iterator.

I am sure there are edge cases (I ran into a couple in my post and I have a few more in mind that I haven’t thought through yet), but I still think it’s worth considering. Some of those edge cases turn out to be simplifications rather than complications to be worked around. For example, try Iterator providing a neat way to differentiate between "Err means the iterator has ended" and "Err means a single item failed."

If anything, I suspect the main problem with this will be the lifetimes around async Iterator- a Stream is a single object produced by the compiler that can thus take advantage of pinning for self-references; the future of an async Iterator is a separate object, making it harder to package up and pass around a stream mid-next. We may end up needing to wrap an async Iterator as a Stream anyway for pragmatic reasons.

Another problem that occurred to me since posting is the syntax of const fn's promotion to runtime fn. Supplying a generic const fn with a non-const T: Trait allows it to gain the “runtime” effect. Under the analogy above, this means supplying a generic fn with a T: async Trait and promoting it to an async fn. This would be extremely useful (one could reuse combinators like unwrap_or_else with async closures) but would make await points in generic code invisible, which people seem to dislike.

8 Likes

Another issue discussed out-of-band with async Trait that I’ll bring up here:

  • async Drop (or other additive effects): probably can just be verboten.

  • async Fn(_) (or really any other one where a method takes arguments):

    The transformation is to turn the method into poll(Pin<&mut self>, &mut Context) format; async FnOnce() -> T ≈ Future<Output = T>. How can that transformation work for methods that take other arguments? Instead, async Trait would have to be "wrap the output in Future, which explicitly doesn’t work for Stream/async Iterator.

    Additionally, it’d be strange if async fn(T) { _ } does not impl async Fn(T).

This “trait effects” system requires figuring out exactly what async Trait means. And even if Stream is async Iterator, I doubt we want Iterator's provided methods to be async in that case.

Iff I can figure out how to express the async Trait “trait effect”, I plan to write a bit more about what the system looks like (as it does seem promising), but I’m currently stuck trying to figure that step out. I think it would be compatible with the recently stabilized Future type if we made Future<Output = T> an alias for async FnOnce() -> T somehow (as that would require the "async trait effect to line up with the explicit Future type and aliasing the method name as well, as well as making a subset of Fn types implementable).

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.