owned this note changed a year ago
Published Linked with GitHub

Fleshed out async fn sugar

Fn traits and async are each complex in isolation. Syntax sugar exists for both of them to bridge this complexity gap and to draw syntactic symmetry between the sugar and how it relates to underlying concepts in the language.

For Fn traits, we want to draw a parallel between fn items and pointers, and Fn trait bounds via parenthesized argument lists and usage of the return arrow ->, and so we've settled on parenthesized generic sugar.

For async, we want as much as possible for people to "just write async" in places that they want to be able to use .await. This means:

  • Users can add async to their fn function()s in order to make them async.
  • Users can add async to their closures in order to make them async.
  • Users can add async to their Fn trait bounds. < this is the case that is under consideration in this meeting.

We intentionally add async on top of the parenthesized generic sugar because that sugar is already well established: users are familiar with the parallel it draws between other fn-like concepts in the language.

While it's useful to think about extensions towards a general async trait bound modifier, deciding to use async in Fn trait bounds does not require us to add async anywhere else the parallel we're drawing here is between async fn and async Fn, not between async Fn and arbitrary async Traits, and I believe this alone carries the weight of the feature.

Risks:

  • async Fn may draw users' attention towards the lack async Trait in general. I don't believe this is a concern, since Fn traits are special already. They are the only traits that allow parenthesized generic sugar; this specialness is a consequence of functions and closures being first-class in Rust, and we're leaning into users' familiarity with them as first-class language concepts in this proposal.

  • There is a risk that async Fn might have different (user observable) semantics than some future async bound modifier. An argument could be made that by using AsyncFn as the trait name, we're saving space by mitigating any issues down the road if we choose a desugaring for async Trait that is inconsistent with the (user observable) details of AsyncFn. I don't believe that this is actually the case, though inconsistency will still arise if we chose an async Trait desugaring that results in async Fn behaving differently than AsyncFn, so we're not saving any space by calling the trait AsyncFn.

Other considerations:

  • LendingFn* compatibility - Using a first-class syntax allows more flexibility on the implementation side. This was argued at length by oli-obk in a thread on the RFC, but tl;dr is that trait aliases are not currently sufficient to move onto LendingFn in the future, so we'd have to add either new language support or a name-resolution hack to support this case. It's worth keeping this in mind, since not all ways of "saving space" are zero-cost.

  • There is a risk of a trait bound naming blow-up if we ever gain async gen or gen closures and settle on AsyncFn* as the naming scheme. Extending these language features (e.g. async gen closures) becomes less intuitive if users must learn that AsyncGenFn() is how it needs to be spelled in trait bounds.


Discussion

Attendance

  • People: TC, CE, Josh, Tyler, eholk, pnkfelix

Meeting roles

  • Minutes, driver: TC

General

I don't believe this is a concern, since Fn traits are special already. They are the only traits that allow parenthesized generic sugar

Josh: They allow a special sugar, but they're still traits, with a trait name, and the sugar uses the trait name.

Josh: I appreciate the point about "just write async and it works." And I agree with that. But at the same time, I don't think that invalidates the point that this is introducing new sugar. This causes the bound to refer to a different trait than the one being used.

CE: But from the user's point of view, it's not a different trait at all, since we're not exposing that. We're modifying the Fn trait.

tmandry: There seem to be different ways to look at it.

Josh: There seem to be two things:

  1. This doesn't name the actual trait that's being used, it's an additional sugar on top.
  2. This names Fn, but it isn't using Fn.

Josh: I'm feeling more flexible on point 1, and I feel like I wouldn't have a blocking objection to a different sugar. It's point 2 that's a sticking point for me.

Examples of sugars that seem fine: async(A, B) -> R, async fn(A, B) -> R

CE: This seems to put too much weight on the details of the Fn* traits themselves. These are special, and what users can see in terms of the implementation isn't something we guarantee.

CE: The only user-visible thing that matters is the part of the signature that represents whether Self is taken by value, by reference, by shared reference, etc.

Josh: Is there any reasonable path by which Fn* becomes the lending traits?

CE: It could happen; we'd have to close a door that says we'd want to have pure lending fns. Each of the types would have to admit an implementation of FnOnce.

Josh: An async Fn is not an Fn, in the literal trait impl sense: you cannot accept an async Fn and pass it to a thing expecting a Fn.

CE:

Josh: What you're saying is that people shouldn't focus on the details of the Fn traits and treat them as entirely compiler magic?

CE: I think there's a middle ground. They are traits, but there's a lot of space we've reserved here. They are special, in parallel to that these are the only callable items within the language, and we have this callable syntax.

Josh: My feeling is that once we have variadic generics, we would start to stabilize the details of the Fn* traits.

CE: My feeling is that these will always be a bit special, in the same way that Drop is kind of special. E.g., if we were to stabilize the Fn* traits today, I'd want to enforce that people can only implement only one of them.

CE: So I'm further along the spectrum that the Fn* traits are special, and I want to lean into that a bit.

Josh: That makes sense that these may be a bit special. I think the real problem I have here is that it names Fn when it's not Fn.

Josh: If we committed to the path where the Fn* traits were the lending traits, then I'd be more OK with this path.

CE: I don't necessarily buy that future argument. The worst case that I see is having a trait alias for AsyncFn

CE: I don't think this closes as big of a door as is being suggested here.

tmandry: I feel like we're running into the function coloring problem.

CE: The only aspect of the coloring that this makes obvious and this happens regardless of what we name this is the bit about passing async Fn to a Fn bound.

tmandry: This is a difficult one, as it's both weaker and stronger.

CE: There are some orthogonal points here.

Josh: If we wanted to go forward with a general trait modifier syntax, I'm be substantially less concerned about this. Whether we end up with that or not, I don't feel like we have an obvious consensus at this time. That's a big part of this for me. I don't want this to be used as precedent to commit us to a general trait modifier syntax, and that sort of thing has happened before.

Josh: At the same time, I'm increasingly convinced by the arguments that we want a sugar of some kind. So we could write down the various things we've proposed as sugars.

CE: Please note that we need to select from sugars that are sufficiently expressive. Some of the proposals above (e.g. async fn() -> T) does not provide a way to express the self/&self/&mut self distinction that is the fundamental motivation for FnOnce/Fn/FnMut.

TC: Let's do it:

  • below, * is short hand for empty string or one of once, mut. (Or capitalized when it appears as part of a trait name e.g. FnOnce))
  • +1 "This is what I would do"
  • +0 "I'm OK with this, leaning positive"
  • -0 "I'm neutral to this, leaning negative"
  • -1 "I would block this"
name async Fn*() -> T AsyncFn*() -> T async fn * () -> T async * () -> T async * |A, B| -> T
nikomatsakis +1 +0 -1 -1 -1
tmandry +1 +1 -0.5 -1 -0.5
Josh -1 +0.75 +0.5 +1 +0.5
pnkfelix +0 -0 -0.5 +0 -1
scottmcm
TC +1 -0.5 -1 -1 -1
CE +1 -1 -1 -0.5 -1
eholk +1 +0 -1 -0.75 -0.5

TC: Silly example I find motivating:

fn f() -> impl async Fn() -> u8 {
    async fn g() -> u8 { 42 }
    async || g().await
}

CE: Could we move this to an open question, the trait bound syntax?

Josh: I'd be OK with that. This is the only concern.

Josh: I am concerned with building inertia behind async Fn by leaving that working in nightly as the only option behind the feature gate. I'm OK with the RFC still saying async Fn (with one mention of the unresolved question up front) as long as they get equal treatment e.g. in nightly and in a blog post.

tmandry: Josh, is there anything you can see that would move your -1 to something else?

Josh: There are two things that would resolve that. I can't say I'd be thrilled with either happening. This all boils down to:

What does async Ident mean in type/bound context:

  • async Trait with some automatable transformation to Trait, and it's consistent with async Fn, and we commit to doing that, then this becomes a -0.
  • async Ident might mean something else (including some transformation of Trait that isn't consistent async Fn), then this stays -1.
  • async Ident will never mean anything in either type or bound position (other than this special case), then I think this becomes -0.5.

TC: Given your number 1 and number 3, taking the union of those, what if we commit that, either way, the semantics of async Fn would represent a fixed point in any design of number 1?

Josh: That's almost true; what we'd also need to rule out is the possibility of using async Ident for something else, e.g. RFC 3628 or something like that.

tmandry: That would be the use of the keyword in type position; this is use in trait position.

TC: Correct. There's no technical conflict here.

Josh: That's true, but I don't know that we can precommit to rejecting certain counterarguments (such as it being confusing to use async Ident for one thing in type position and a different thing in bound position).

TC: As in, people would raise arguments of confusion.

tmandry: How would you feel about a future in which async Trait is a different trait from Trait?

Josh: I treat that as another case of number 1. It would be consistent with async Fn. (I think some folks might have concerns with name resolution weirdness.)

TC: Here's the path I'm seeing, in addition to raising the open questions here:

  1. Deciding whether we could commit to the semantics of async Fn as a fixed point in the design space of general async trait bound modifiers.
  2. Deciding whether we could reject other meanings of async $ident, e.g. in type position, e.g. RFC 3628, or that we could adopt a semantic for this that is compatible with async closures, e.g. TC's propose for async arrow syntax.

Josh: Part of the reason I'm -1 on the async Fn bound syntax is that I'm also -1 on accepting async Trait as a general trait transformer mechanism or on rejecting async $ident entirely.

TC: Other than things like RFC 3628, what other sort of possibilities should we consider here, in terms of deciding whether to close a door?

Josh: There aren't others that I can think of right now. But async is a pervasive part of the language and I think it's very likely to come up in other ways. I don't off-hand want to prereject other ideas we may come up with here.

lower case sugar

  • async fn(A, B) -> C => async Fn(A, B) -> C
  • async fn mut(A, B) -> C => async FnMut(A, B) -> C
  • async fn once(A, B) -> C => async FnOnce(A, B) -> C
    • Does once work as a contextual keyword here?

(The main part of the meeting ended here.)


just async

  • async(A, B) -> C => async Fn(A, B) -> C
  • async mut(A, B) -> C => async FnMut(A, B) -> C
  • async once(A, B) -> C => async FnOnce(A, B) -> C
    • Does once work as a contextual keyword here?

eholk: Duality between async -> T as sugar for impl Future<Output = T> and async(A, B) -> T as sugar for an async fn?

Josh: Or async T, yeah. Either way, there's a nice analogy.

TC: Apropos of some things above, here's the async arrow proposal that had been earlier discussed.

I thought of a counterproposal to RFC 3628 that fits more naturally with async closures. Async arrow bounds and block type annotations:

fn f() -> impl async -> u8 {
    async -> u8 { 42 }
}

The symmetry here is:

  • async Fn() -> T is a function that returns a future that resolves to T.
  • async -> T is a future that resolves to T.

This is similar to eholk's more general impl Future -> T proposal, but is shooting for this consistency with async closures, and takes advantage of the fact that we already have this keyword reserved.

Unlike RFC 3628, I keep the impl because I think that's important for explaining how things work unless we go generally back to some form of bare trait syntax.

As for the block syntax, since async blocks really are a kind of closure-like thing (e.g. one can return from them), it feels like this symmetry should hold:

fn main() {
    _ = || -> u8 { 42 };
    _ = async -> u8 { 42 };
}

Josh: This creates a new mental model about -> as "a thing that resolves to this".

TC: That's an existing part of the mental model, e.g. in async fn foo() -> T the -> means "resolves to T".

TC: Here's how I'd justify the -> in the block syntax:

fn f() -> impl async -> u8 {
    async -> u8 {
      return 42;
    }
  }
}

Josh:

fn takes_future(impl AsyncFn() -> T) { ... }

takes_future(async { ... }) // ???
takes_future(|| async { ... }) // in the RFC

closure syntax

async * |A, B| -> T

Select a repo