--- title: "Design meeting 2024-07-23: Async closures" tags: ["T-lang", "design-meeting", "minutes"] date: 2024-07-23 discussion: https://rust-lang.zulipchat.com/#narrow/stream/410673-t-lang.2Fmeetings/topic/Design.20meeting.202024-07-23 url: https://hackmd.io/d4M1klVURVOr_ZZQuSgAtw --- ### 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 Trait`s, 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: ```rust 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: ```rust 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: ```rust 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: ```rust fn f() -> impl async -> u8 { async -> u8 { return 42; } } } ``` Josh: ```rust fn takes_future(impl AsyncFn() -> T) { ... } takes_future(async { ... }) // ??? takes_future(|| async { ... }) // in the RFC ``` ### closure syntax `async * |A, B| -> T`