RFC: Return Type Notation
Return type notation (RTN) gives a way to reference or bound the type returned by a trait method. The new bounds look like T: Trait<method(..): Send> or T::method(..): Send. The primary use case is to add bounds such as Send to the futures returned by async fns in traits and -> impl Future functions, but they work for any trait function defined with return-position impl trait (e.g., where T: Factory<widgets(..): DoubleEndedIterator> would also be valid).
This RFC proposes a new kind of type written <T as Trait>::method(..) (or T::method(..) for short). RTN refers to "the type returned by invoking method on T".
To keep this RFC focused, it only covers usage of RTN as the Self type of a bound or where-clause. The expectation is that, after accepting this RFC, we will gradually expand RTN usage to other places as covered under Future Possibilities. As a notable example, supporting RTN in struct field types would allow constructing types that store the results of a call to a trait -> impl Trait method, making them more suitable for use in public APIs.
Examples of RTN usage allowed by this RFC include:
where <T as Trait>::method(..): Send- (the base syntax)
where T: Trait<method(..): Send>- (sugar for the base syntax with the (recently stabilized) associated type bounds)
where T::method(..): Send- (sugar where
Traitis inferred from the compiler)
- (sugar where
dyn Trait<method(..): Send>- (
dyntypes take lists of bounds)
- (
impl Trait<method(..): Send>- (...as do
impltypes)
- (...as do
I would like to propose a tweak to the desguaring of RTN, which simplifies the limitation to "only in the Self of bounds" in the short term, and opens up some nicer future possibilities.
The RFC describes the T::method(..) syntax as projecting a type, and then using this type in a bound as a separate step. This approach avoids the implementation complexity of typeof, but still shares an expressiveness and ergonomics papercut with it: there is no way to name its result or unify it with another type, so it must be spelled out in full at each occurrence.
By comparison, while normal associated types do support a projection syntax, they also crucially support a syntax for naming and unification. For example, given the bound I: IntoIterator you can write I::IntoIter, but you cannot write I::IntoIter = i32. Fortunately, you can instead write I: IntoIterator<IntoIter = J>, or I: IntoIterator<IntoIter = i32>, or even something like I: IntoIterator<IntoIter = Foo<I, J, K>>.
This is sort of the type level analog to let bindings. Without it you can still express some things, but a lot of them are awkward, and other things are impossible. I understand that the RFC is focused on something minimal to be expanded on later, but I think this is important enough to consider from the start, and fortunately the "only Self of bounds" limitation lines up with this neatly.
So the tweak I propose is this: T::method(..): Send should be shorthand for the associated bound T: Trait<$Method: Send>, rather than the projection bound T::$Method: Send or T::method::Output: Send. The associated bound T: Trait<method(..): Send> becomes T: Trait<$Method: Send> directly. (Or, using the method ZST, T::method: FnStatic<_, Output: Send> and T: Trait<method: FnStatic<_, Output: Send>> respectively.)
This opens the door for T::method(..) -> U to be a full bound of its own, shorthand for T: Trait<$Method = U>, which enables multiple and/or unifying uses of U without re-specifying T::method(..). (Or, again using the method ZST, T::method: FnStatic<_, Output = U>.) This in turn brings the RTN sugar closer to the Fn trait sugar, making for a smoother transition between the sugar and whatever fully-general form(s) we get in the future, such as the const auto trait idea. We might also extend Fn trait sugar with the equivalent parameter elision and associated bound syntax, as in F: Fn(..): Send.
@rpjohnst
I like the suggestion of T: Trait<method() -> X> as a possible future syntax (and I will add it into the list of options). That said, does the suggestion to "change the desugaring" have any practical impact in terms of the set of Rust programs that are accepted?
It seems to me to be more of a change in how we think about things, is that correct?
I believe that is correct, because of the limitation to Self types of bounds, but I haven't tried to break it. (For all I know, though, the existing prototype implementation already works like this.)
It does also change how we might relax that limitation in the future, though- if T::method(..) is not a self-contained type projection, but part of two bound shorthands (T::method(..): Send, T::method(..) -> U), then we might prefer to write the examples from this section like this instead:
trait DataFactory {
async fn load(&self) -> Data;
}
fn load_data<D: DataFactory<load(..) -> L>, L>(data_factory: D) {
let load_future: L = data_factory.load();
await_future(load_future);
}
fn await_future<D: DataFactory<load(..) -> L>, L>(argument: L) -> Data {
argument.await
}
struct Wrap<'a, D: DataFactory<load(&'a D) -> L>, L> {
load_future: L, // the future returned by `D::load`.
}
This does look like it would subtly change some quantifier scopes, but maybe they would wind up being closer to what you would get if you wrote this out by hand with generic associated types?
@rpjohnst
The RFC is written with RTNs acting as a kind of "pseudo-associated type"[^1]. The idea is that you can think of traits as having an internal $Method<...> GAT with some undefined set of parameters and method(..) as the user-facing syntax for accessing it. So I would like to see method(..) be usable in the same way that GATs are -- that means being able to do T::method(..): Send but also T: Trait<method(..): Send>.
I agree with your suggestion that should also mean being able to do T: Trait<method(..) -> X> in the same way that you can do T: Trait<$Method = X> (as you suggested). I see this missing point as a gap in the RFC as written.
But I don't quite follow why we would want to rewrite the DataFactory example. Continuing with my analogy above, I think that just as one can write T::$Method<..> as a standalone type, you should be able to do T::method(..).
I guess my point of confusion is this paragraph that you wrote:
This opens the door for
T::method(..) -> Uto be a full bound of its own, shorthand forT: Trait<$Method = U>, which enables multiple and/or unifying uses ofUwithout re-specifyingT::method(..). (Or, again using the method ZST,T::method: FnStatic<_, Output = U>.) This in turn brings the RTN sugar closer to theFntrait sugar, making for a smoother transition between the sugar and whatever fully-general form(s) we get in the future, such as theconstauto trait idea. We might also extendFntrait sugar with the equivalent parameter elision and associated bound syntax, as inF: Fn(..): Send.
Again here I think that making the Fn sugar closer seems very good, but I don't quite know how it is blocked by also allowing T::method(..) to be usable as an associated type.
[^1]: In earlier drafts I tried to introduce the term "associated return type" but it didn't "stick". I still kind of like it except that it doesn't scale to e.g. top-level functions.
Ah, I could have spelled that out more clearly- the issue I see is that if T::method(..) -> X is sugar for the bound T: Trait<$Method = X>, then for consistency T::method(..) would be sugar for T: Trait<$Method = ()>, with the implicit -> () that we have everywhere else.
It's probably a good idea to have a projection syntax as well, but since the RFC limits the T::method(..) syntax to bounds anyway, I think that can be worked out as a future extension, e.g. whether we make T::method(..) do double duty, or come up with an alternative syntax to avoid the ambiguity.
It's probably a good idea to have a projection syntax as well, but since the RFC limits the
T::method(..)syntax to bounds anyway, I think that can be worked out as a future extension, e.g. whether we makeT::method(..)do double duty, or come up with an alternative syntax to avoid the ambiguity.
The RFC doesn't limit it to bounds, it limits it to "self position" -- i.e., the RFC allows for T::method(..): Send. I don't expect that to be widely used though and of course (as you say) it's not strictly necessary, it's more to set the precedent and just be a bit less surprising.
I see your point about consistency, but there are other kinds of consistency to consider, e.g., T: Trait<Foo: Send> allows to T::Foo.
I think I will add this as an "unresolved question". I expect we will stabilize the bound form first anyway for implementation reasons.
The RFC doesn't limit it to bounds, it limits it to "self position" -- i.e., the RFC allows for
T::method(..): Send. I don't expect that to be widely used though and of course (as you say) it's not strictly necessary, it's more to set the precedent and just be a bit less surprising.
Right, this is all I meant by "limited to bounds." I was thinking of T::method(..): Send as sugar for T: Trait<$Method: Send> or maybe T::method: FnStatic<_, Output: Send>- sort of an extension of associated bound syntax, which doesn't quite have the same conflict with T::method(..) -> (), because it wouldn't make sense to write T::method(..) -> X: Send anyway.
I guess if we consider that form to be rare enough (i.e. we expect everyone to use T: Trait<method(..): Send> most of the time instead) it could be worth delaying its stabilization for this unresolved question, but it seems reasonable either way.
The typeof syntax could be extended to deduce a type given variables with certain types. For the HealthCheck example, it might look like the following:
fn start_health_check<H>(health_check: H, server: Server)
where
H: HealthCheck + Send + 'static,
for<'a> typeof for (health_check: &'a mut H, server: Server) H::check(health_check, server): Send,
or even
fn start_health_check<H>(health_check: H, server: Server)
where
H: HealthCheck + Send + 'static,
for<'a> typeof for (health_check: &'a mut H, server: Server) health_check.check(send): Send,
This avoids the need for a dummy function but is still considerably more verbose than RTN.
@bluebear94 I pushed a variant of your proposal using let as a comment. I also added an unresolved question regarding @rpjohnst's point about T::foo(..) as a standalone type.
(Side note, I was experimenting with marking comments as "Resolved". I don't think I like it, it makes it hard to follow the comment thread after the fact, but I can't figure out how to undo it.)
(@nikomatsakis you can expand the comment, click the "..." then click "Unhide")
@kennytm thanks! That option wasn't showing up before, but it is now.
@rfcbot fcp merge
This proposal has been under discussion for a long time and, in this first week, all the feedback has been incorporated. I'm going to go ahead and kick off the checkboxes towards final-comment-period.
Team member @nikomatsakis has proposed to merge this. The next step is review by the rest of the tagged team members:
- [x] @joshtriplett
- [x] @nikomatsakis
- [ ] @pnkfelix
- [x] @scottmcm
- [x] @tmandry
No concerns currently listed.
Once a majority of reviewers approve (and at most 2 approvals are outstanding), this will enter its final comment period. If you spot a major issue that hasn't been raised at any point in this process, please speak up!
cc @rust-lang/lang-advisors: FCP proposed for lang, please feel free to register concerns. See this document for info about what commands tagged team members can give me.
I'd love to summaries here discussion around these couple of posts:
- https://blaz.is/blog/post/lets-pretend-that-task-equals-thread/
- https://matklad.github.io/2023/12/10/nsfw.html
While I don't think this summary should affect the specific decision on this RFC, I think it helps to illuminate the surrounding context.
The main motivation for RTN is to be able to say that a particular future is Send. We care about
Sendness of futures because, roughly, only Send futures work with work-stealing executors.
Peeling Rust specific abstractions, the picture is this:
We have an asynchronous computation. The computation is suspended, meaning that it's not actually executing, and instead is just a bunch of memory waiting for a wake up. Specifically, this memory is roughly "local variables live across a yield point". It seems that the safety claim here is:
To safely migrate this computation to a different OS-thread, all suspended state must be
Send.
It is important to realize that this claim is actually wrong. It is totally fine to migrate async
tasks across threads even if they hold onto non-Send data across the yield point. Two proofs by
example here are:
- OS-threads, which can hold onto all kinds of non-
Senddata before a blocking syscall, and end up being woken up on a different CPU core after syscall returns. - Goroutines, which similarly can use local non-thread safe data and migrate between OS threads.
Non-the-less, it would be wrong to just remove Send bounds from work-stealing executors --- while
they are not needed to protect us from general data races, they end up outlawing one very specific
scenario where unsoundly-bad things can happen --- smuggling non-thread-safe data between threads
using thread locals. The following example breaks if we just remove Send bounds:
async fn sneaky() {
thread_local! { static TL: Rc<()> = Rc::new(()); }
let rc = TL.with(|it| it.clone());
async {}.await;
rc.clone();
}
One can imagine alternative world, where:
SendandSynctraits are not definitionaly dependent on OS threads.- There's only unsafe API for accessing thread locals, with one part of the contract being "make sure that the data you get from a thread local doesn't end up in a different OS thread".
In that world, both work-stealing and thread-per-core executors would work fine with non-Send
futures. So, there wouldn't be any need to specify or constrain the sendness of async functions, and
moreover there wouldn't be a need to be generic over sendness. As such, that world would have much
less motivation for either RTN or trait transformers.
In other words, this is an instance of a classic Rust problem of composing unsafe abstractions.
The two unsafe abstractions here are:
- Thread locals
- Work-stealing async tasks
Each can be made safe in isolation, but the combination of the two end up being unsound.
From where I stand, I am 0.85 sure that we've picked the wrong one of the two in this case: I'll
gladly use a context object in lieu of a thread local, or write some unsafe inside my global
allocator, if that means that crates like may are sound, and if I don't
need to further paint async part of the ecosystem into Send, !Send and ~Send colors.
That being said, I don't see a reasonable way we can rectify the mistake while maintaining backwards compatibility. Given that the issues were somewhat widely discussed and no-one else seemed to suggested a workable approach either, I am skeptical that this is at all possible.
So it seems reasonable to accept this part of Rust as is, and instead try to address the repercussions, where something like RTN does seem necessary.
I'm unsure how one continues to write safe abstractions with such a proposed change to Send. Aside from the mentioned pthread issues, WaitForDebugEventEx is an example of a Windows API that must be called from the same thread that spawned a process, and which I specifically use in a !Send async context. In the end these all come down to thread locals, this part I understand, but I'm unsure how one writes a non-panicky sound abstraction without the ability to guard things being sent cross-thread. With an unsafe thread local, the implementation must now dynamically check it hasn't secretly been moved across OS threads by an implementation that thinks its context restoration is 'good enough', but that simply can't cheat the OS itself. Most likely it panics if it has. This seems like a horrible degradation of efficiency and consistency for such APIs, unless I'm missing something.
Hi @matklad,
I agree that Send is not a perfect fit for async tasks as is. Ideally we would (for example) distinguish Rc values that were given to the task initially from Rc created within the task itself. I'd like to explore how to do that. However, I don't think that really changes the need for this RFC. It is possible to create tasks that are "tightly tied' to the current thread in other ways. For example, a task might introduce something into thread-local data and then retain a raw pointer to that information. This is a legitimate optimization that I've seen a lot of systems use to enable caching without the need for expensive synchronization.
On Fri, Jun 14, 2024, at 1:46 PM, Alex Kladov wrote:
I'd love to summaries here discussion around these couple of posts:
• https://blaz.is/blog/post/lets-pretend-that-task-equals-thread/ • https://matklad.github.io/2023/12/10/nsfw.html
While I don't think this summary should affect the specific decision on this RFC, I think it helps to illuminate the surrounding context.
The main motivation for RTN is to be able to say that a particular future is Send. We care about
Sendness of futures because, roughly, onlySendfutures work with work-stealing executors.Peeling Rust specific abstractions, the picture is this:
We have an asynchronous computation. The computation is suspended, meaning that it's not actually executing, and instead is just a bunch of memory waiting for a wake up. Specifically, this memory is roughly "local variables live across a yield point". It seems that the safety claim here is:
To safely migrate this computation to a different OS-thread, all suspended state must be
Send.It is important to realize that this claim is actually wrong. It is totally fine to migrate async tasks across threads even if they hold onto non-
Senddata across the yield point. Two proofs by example here are:• OS-threads, which can hold onto all kinds of non-
Senddata before a blocking syscall, and end up being woken up on a different CPU core after syscall returns. • Goroutines, which similarly can use local non-thread safe data and migrate between OS threads.Non-the-less, it would be wrong to just remove
Sendbounds from work-stealing executors --- while they are not needed to protect us from general data races, they end up outlawing one very specific scenario where unsoundly-bad things can happen --- smuggling non-thread-safe data between threads using thread locals. The following example breaks if we just removeSendbounds:async fn sneaky() { thread_local! { static TL: Rc<()> = Rc::new(()); } let rc = TL.with(|it| it.clone()); async {}.await; rc.clone(); }
One can imagine alternative world, where:
•
SendandSynctraits are not definitionaly dependent on OS threads. • There's only unsafe API for accessing thread locals, with one part of the contract being "make sure that the data you get from a thread local doesn't end up in a different OS thread".In that world, both work-stealing and thread-per-core executors would work fine with non-
Sendfutures. So, there wouldn't be any need to specify or constrain the sendness of async functions, and moreover there wouldn't be a need to be generic over sendness. As such, that world would have much less motivation for either RTN or trait transformers.In other words, this is an instance of a classic Rust problem of composing unsafe abstractions https://smallcultfollowing.com/babysteps/blog/2016/10/02/observational-equivalence-and-unsafe-code/.
The two unsafe abstractions here are:
• Thread locals • Work-stealing async tasks Each can be made safe in isolation, but the combination of the two end up being unsound.
From where I stand, I am 0.85 sure that we've picked the wrong one of the two in this case: I'll gladly use a context object in lieu of a thread local, or write some
unsafeinside my global allocator, if that means that crates likemayhttps://docs.rs/may/latest/may/ are sound, and if I don't need to further paint async part of the ecosystem intoSend,!Sendand~Sendcolors.That being said, I don't see a reasonable way we can rectify the mistake while maintaining backwards compatibility. Given that the issues were somewhat widely discussed and no-one else seemed to suggested a workable approach either, I am skeptical that this is at all possible.
So it seems reasonable to accept this part of Rust as is, and instead try to address the repercussions, where something like RTN does seem necessary.
— Reply to this email directly, view it on GitHub https://github.com/rust-lang/rfcs/pull/3654#issuecomment-2168492366, or unsubscribe https://github.com/notifications/unsubscribe-auth/AABF4ZQV4FUZVGFM5Y2DEKTZHMT7FAVCNFSM6AAAAABI2KAHLSVHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDCNRYGQ4TEMZWGY. You are receiving this because you were mentioned.Message ID: @.***>
@rfcbot fcp merge
This project goal has been under development and discussion for some time. Since discussion on the thread has been relatively quiet, I'm going to go ahead and propose to merge it now. There will be a long list of checkboxes regardless so I want to get that discussion up and going.
EDIT: Posted on the wrong PR!
I put a lot of input into this RFC in the early design stages and while it was a draft, and I'm happy with how it turned out! Thanks @nikomatsakis for being the one to drive the RFC forward, and to everyone who participated in the many design meetings we had on this feature.
@rfcbot reviewed
:bell: This is now entering its final comment period, as per the review above. :bell:
@matklad
From where I stand, I am 0.85 sure that we've picked the wrong one of the two in this case: I'll gladly use a context object in lieu of a thread local, or write some
unsafeinside my global allocator, if that means that crates likemayare sound, and if I don't need to further paint async part of the ecosystem intoSend,!Sendand~Sendcolors.
Re-reading your comment in full, I see I responded too hastily, and you raised the very point I was making. Apologies for that. In any case, I generally agree with your conclusion.
That said, I have noticed thread-locals cropping up more and more often as an annoying soundness hole (most recently in duchess and salsa), and I am wondering whether we should consider a way to make using them more 'opt-in' (or at least 'opt-out'). This is connected to the context and capabilities idea that @tmandry blogged about some time past. Put another way, I am not quite as pessimistic as you are about eventually changing how this works.
That said, I don't want to block async progress while we figure that out (and there's a chance that it doesn't get us all the way there, as @CraftSpider pointed out). And as you said, there is backwards compatibility to consider. I think ultimately it'll bottom out in a lot more futures being Send (basically, those that opt-out from accessing thread-local state or other such things), but likely the basic need to distinguish send from not-send will remain.
Plus (as the RFC points out) this feature is more general regardless.
Nit: This doc takes a bit too long, as written, to establish firmly that the ".." in the syntax <method>(..): <Bound> is not a meta-notation but rather a concrete Rust type-expression notation, standing for "regardless of what concrete arguments are given to the method, the return type of the method call is bounded the given bound."
At least for me (and this is after I had participated in design meetings on this subject), when I first picked up this RFC and read it, my gut upon encountering the "method(..)" syntax was to interpret the ".." as placeholder for some sort of concrete arguments (I know that it doesn't say whether those arguments would be type-expressions, value-expressions, or something else; my brain was happy to to let the doc fill that in later).
The main hints that served to correct the above misimpression, at least for me, are:
- The occurrences (which start relatively late in the doc) of "the method, regardless of what arguments it is given", such as:
The
shutdown(..)notation acts like an associated type referring to the return type of the method. The boundHC: HealthCheck<shutdown(..): Send>indicates that theshutdownmethod, regardless of what arguments it is given, will return aSendfuture. - The grammar that explicitly makes it clear that ".." is itself to be treated as a token, not a grammatical meta-notation, namely:
| Type "::" MethodName "(" ".." ")" // 👈 new | "<" Type as TraitName Generics? ">" "::" MethodName "(" ".." ")" // 👈 new
@rfcbot reviewed
I think ultimately it'll bottom out in a lot more futures being
Send(basically, those that opt-out from accessing thread-local state or other such things), but likely the basic need to distinguish send from not-send will remain.
Side note: I thought about this some around the time of @matklad's blog post on the topic and came to a similar conclusion. In fact, I have draft about this that I need to actually post..
Nit: This doc takes a bit too long, as written, to establish firmly that the ".." in the syntax
<method>(..): <Bound>is not a meta-notation but rather a concrete Rust type-expression notation, standing for "regardless of what concrete arguments are given to the method, the return type of the method call is bounded the given bound."
Good feedback. I'll consider some minor edits to clarify.
@rfcbot reviewed
@rfcbot fcp reviewed
The final comment period, with a disposition to merge, as per the review above, is now complete.
As the automated representative of the governance process, I would like to thank the author for their work and everyone else who contributed.
This will be merged soon.
The team has accepted this RFC, and it's now been merged.
Thanks to @nikomatsakis for pushing forward on this important work, to @compiler-errors for the experimental implementation, and to all those who reviewed the RFC and provided useful feedback.
For further updates on this work, follow the tracking issue:
- https://github.com/rust-lang/rust/issues/109417