rust-lang / rfcs

RFCs for changes to Rust
https://rust-lang.github.io/rfcs/
Apache License 2.0
5.97k stars 1.57k forks source link

RFC: introduce the flavor syntactic design pattern #3710

Closed nikomatsakis closed 2 weeks ago

nikomatsakis commented 1 month ago

Summary

This RFC unblock the stabilization of async closures by committing to K $Trait (where K is some keyword like async or const) as a pattern that we will use going forward to define a "K-variant of Trait". This commitment is made as part of committing to a larger syntactic design pattern called the flavor pattern. The flavor pattern is "advisory". It details all the parts that a "flavor-like keyword" should have and suggests specific syntax that should be used, but it is not itself a language feature.

In the flavor pattern, each flavor is tied to a specific keyword K. Flavors share the "infectious property": code with flavor K interacts naturally with other code with flavor K but only interacts in limited ways with code without the flavor K. Every flavor keyword K should support at least the following:

Some flavors rewrite code so that it executes differently (e.g., async). These are called rewrite flavors. Each such flavor should have the following:

Binding recommendations

Existing flavor-like keywords in the language do not have all of these parts. The RFC therefore includes a limited set of binding recommendations that brings them closer to conformance:

Not part of this RFC

The Future Possibilities discusses other changes we could make to make existing and planned flavors fit the pattern better. Examples of things that this RFC does NOT specify (but which early readers thought it might):

Rendered

clarfonthey commented 1 month ago

So, I understand why you're referring to these as colours, since "coloured functions" is what the larger programming language community uses as a term, but I think that the naming of effects should be used instead, especially since that's at least what the current Rust WGs have been settling on.

Can you think of an example of a "colour" by this metric that wouldn't be an effect? And even then, I think it would be say that the colour should have an effect on everything it labels, so, that meaning would still apply.

The biggest benefit to this is that "effects" also clearly evoke the purpose of the colouring: the async effect means that everything is designed for an async context, and the meaning is that return values are wrapped in futures. Similarly, the const effect means that everything is designed to be evaluable in const context.

Also just as a small picky addition: that bicycle emoji is impossibly small without zooming in and while I appreciate the joke, it is unnecessarily opaque and adds in extra effort where the word "bike" or "bicycle" or "bikeshed" would probably be a lot clearer.

nikomatsakis commented 1 month ago

I avoided the term "effect" for a few reasons.

One of them is that I think that it is overall kind of jargon. What's more, my observation is that it is divisive jargon, as people bring pre-conceived notions of what it ought to mean, and not everything that I consider a "color" fits into those notions.

My take on an effect is that it is some kind of "operation" that occurs during execution, such as a write to a specific memory region, a panic, a memory allocation, etc. It's reasonable to model this kind of effect as a "function" you can call when that event occurs (perhaps with some arguments).

From what I can tell, this definition lines up with Koka (which is a very cool language). However, Koka is also (I believe) based on Continuation Passing Style, which means that simple function calls get a lot more power. This allows them to model e.g. generators or exceptions as effects.

To my mind, this is kind of cheating, or at least misleading. In particular, we can't "just" port over Koka's abstractions to Rust because we also have to account for rewrites.

In any case, I'm open to a terminology discussion, though personally I'd be inclined not to rename colors to effects, but perhaps to rename filter colors to effect colors or effect-carrying colors.

EDIT: (added as a FAQ)

rpjohnst commented 1 month ago

Koka's use of CPS is not at all fundamental to its semantics of effects- it is an implementation detail. The semantically-interesting thing, shared in common with other languages' "(algebraic) effects," even when they are not implemented via CPS, is that those effect operations can be handled by suspending and resuming the program.

Rust also does the same thing via a different implementation strategy, a related lowering to state machines. It would be perfectly legitimate to call things like .await or yield or yeet "effect operations" regardless of their implementation strategy.

programmerjake commented 1 month ago

one more effect/color came up: strictfp (or whatever you want to call it) -- it is an opt-in allowing code to run with non-default fp environment.

clarfonthey commented 1 month ago

I avoided the term "effect" for a few reasons.

One of them is that I think that it is overall kind of jargon. What's more, my observation is that it is divisive jargon, as people bring pre-conceived notions of what it ought to mean, and not everything that I consider a "color" fits into those notions.

My take on an effect is that it is some kind of "operation" that occurs during execution, such as a write to a specific memory region, a panic, a memory allocation, etc. It's reasonable to model this kind of effect as a "function" you can call when that event occurs (perhaps with some arguments).

I mean, that is fair that it does give you some sort of preconceived notion, although I think that we've done a pretty good job reconfiguring some of these terms in Rust. To me, both "colour" and "effect" are equally jargon, but "effect" gives me at least an idea of what the jargon means, whereas "colour" gives me nothing at all. Like, if you're going to go with jargon at all, then you should at least try to use jargon that gives people a basic idea that they can latch onto after they know what the jargon means— this is why we use terms like "result" instead of "flargleflorpus" in Rust.

I would also additionally like to challenge that "effect" strongly confers the notion that something occurs at runtime, although we could make everyone upset and use "affect" instead. To me, this merely affects the things that are labelled with it, which… actually, that's two arguments for "affect" over "effect"…

Lokathor commented 1 month ago

I can definitely say that "color" is absolutely a jargon word when used for functions.

Separately, the bike emoji is cute to put in the RFC, but it doesn't render very well all the time. On my particular device, it's a small gray bike in a gray background tele-type text span, and it ends up almost disappearing from view unless you know you're looking for it. Might want to use an actual word like "bikeshed" instead.

programmerjake commented 1 month ago

Separately, the bike emoji is cute to put in the RFC, but it doesn't render very well all the time. On my particular device, it's a small gray bike in a gray background tele-type text span, and it ends up almost disappearing from view unless you know you're looking for it.

maybe use a different bicycle character: 🚴 -- it likely renders with more color. (i tried using VS15 (U+FE0E) for the text style variant, but that seems to no longer work on my android phone, iirc i saw something about unicode deprecating that -- turns out it is a bug that was just recently fixed in chromium https://issues.chromium.org/issues/40628044)

nikomatsakis commented 1 month ago

@rpjohnst

The semantically-interesting thing, shared in common with other languages' "(algebraic) effects," even when they are not implemented via CPS, is that those effect operations can be handled by suspending and resuming the program.

This is what I was referring to, yes. Basically, doing those transformations in an omnipresent way. This has more to do with what it would mean for us to support "user-defined" colors than anything else (which obviously is way out of scope, especially for this RFC.)

Diggsey commented 1 month ago

This is... a lot to take in.

This RFC proposes a lot of magic but doesn't provide a concrete example (eg. with async). It describes being able to apply "async" to get a variation of a trait, but I'm not convinced that's... good? For example AsyncRead is already defined by both tokio and futures crates, and neither of them is a mechanical translation of the non-async Read trait. async-std was an experiment in a more direct translation of the standard library to the async world, and I think it succeeded in showing that wasn't good enough - tokio has been so successful partly because it tailors things for the async use-case.

Also, these colours are described as "rewriting" the item in question, but a big problem with Rust's async is that we can't even express many of the types (like async closures) yet, so what exactly are these types going to be rewritten into?

kennytm commented 1 month ago

Will this RFC or the future consider commitment about multi-colored items

  1. multi-colored blocks does not seem worthwhile. K9 { K7 { K8 { $expr } } } is clearer than K4 K6 K5 { $expr } in emphasizing the application order.
  2. multi-colored functions K1 K2 K3 fn $name() needs to be supported, given the precedence of const unsafe fn and async unsafe fn, and unlike the blocks you can't really break it into smaller parts
    • currently the order of the existing colors are fixed: const async unsafe fn $name(). But with more rewriting color the order does make a difference (try { async { e } }async { try { e } }). Meaning that async try fn f() and try async fn f() should both be allowed and result in different types when calling f(). And the current strict const async unsafe order should be relaxed.
    • needs to decide whether calling K1 K2 K3 fn f() -> T produces 🚲K1<🚲K2<🚲K3<T>>> or 🚲K3<🚲K2<🚲K1<T>>>
    • does async async async async fn f() make sense?
    • or one could require that an item can only take at most 1 rewrite color, but it seems unnecessarily restrictive.
    • can filtering color collapse i.e. const const const const unsafe unsafe unsafe unsafe fn f() be acceptable and considered equivalent to const unsafe fn f()
  3. multi-colored closure K1 K2 K3 move || {} follows the same principle of multi-colored functions
  4. multi-colored traits K1 K2 K3 FnOnce() :thinking:
rpjohnst commented 1 month ago

But with more rewriting color the order does make a difference (try { async { e } }async { try { e } }). Meaning that async try fn f() and try async fn f() should both be allowed and result in different types when calling f().

This is totally avoidable and probably should be avoided. This has already been the subject of a lot of discussion: for example, see boats' https://without.boats/blog/poll-next/ and my https://www.abubalay.com/blog/2024/01/14/rust-effect-lowering

The general idea is that effect-carrying colors do not really make sense to compose via nesting. This is less obvious with try + async because you can get the behavior you want from impl Future<Output = Result>, but Result<impl Future> is already fairly useless, and it really breaks down when you compose multiple colors that can suspend and resume. For example neither impl Iterator<Item = impl Future> nor impl Future<Output = impl Iterator> are really workable, which is why the ecosystem has the combined Stream/AsyncIterator trait instead, with a single poll_next method that can signal either kind of suspension.

So if this RFC were to cover this at all, I would expect it to lean toward supporting multi-color blocks, but with the ordering of the colors being semantically irrelevant. (Indeed this is essentially what makes "algebraic effects" "algebraic.")

rpjohnst commented 1 month ago

@nikomatsakis

Basically, doing those transformations in an omnipresent way.

Putting the colors/effects in the type system prevents this from being an omnipresent thing. Koka uses a selective CPS transform that only touches effectful functions; Rust equivalently only applies the state machine transformation to async (and eventually gen) functions.

My point is really that "effect" need not imply anything so deep about the language that it does not already apply to Rust, and specifically that Koka's use of CPS is not really relevant to how Rust might use the term.

clarfonthey commented 1 month ago

This is a small nitpick, but since this is kind of a theory-heavy RFC, it felt worth pointing out that:

(Indeed this is essentially what makes "algebraic effects" "algebraic.")

…is not true. Things can be algebraic and also order-dependent, and so it's not immediately obvious why we should adopt an order-independent approach. Sure, the example you gave was reasonable, but it's unclear whether all future versions of effects/colours will fit those descriptions.

nikomatsakis commented 1 month ago

@rpjohnst

My point is really that "effect" need not imply anything so deep about the language that it does not already apply to Rust, and specifically that Koka's use of CPS is not really relevant to how Rust might use the term.

OK, I see. I stand corrected. Cool! I will weaken my FAQ answer later today. =)

I still prefer 'color' as the term over 'effect', but I guess I would say...I am persuadable. I agree that color is itself jargon but it strikes me as far more approachable and evocative jargon than effect. I think there's a reason the blog post was called "what color is your function" and not "what effects does your function have".

nikomatsakis commented 1 month ago

@Diggsey

This is... a lot to take in.

I think it is a lot less to take in than it may appear. I view this RFC as documenting existing patterns and "rounding them out" more than it is creating new ones. The only brand new thing in this RFC is committing to some syntax like async<T> or async -> T (similar to what is proposed in #3628). The rest is already part of accepted RFCs, e.g. RFC #3668 for async closures proposed the async Fn syntax. This RFC just says "we'll use the same syntax for future async traits and for const trait".

For example AsyncRead is already defined by both tokio and futures crates, and neither of them is a mechanical translation of the non-async Read trait

The RFC is not proposing a mechanical translation. async Fn is not a mechanical translation of the Fn trait. What we are saying is that the async keyword can be used as a prefix to identify translations of some kind, not that they are mechanical.

Does that make sense, @Diggsey ?

(I plan to create a FAQ from this conversation once we reach a fix point.)

GoldsteinE commented 1 month ago

I feel like async Read is the confusing part. Fn is already a deeply magical trait, having a magic translation to another magical trait is not that weird. Read, on the other hand, is currently not magical at all. Having async Read implies either making Read magical, or providing a way for a user to specify an “async version” of a trait, and it’s not clear how that would work.

I think that “AsyncRead should get a magical syntax if we ever add it to std” is the most novel part of this RFC, not async<T>.

nikomatsakis commented 1 month ago

OK, I want to engage more deeply on the naming question. It's important and I don't think color is necessarily optimal. Here are some thoughts.

When it comes to names, I think there are several qualities that the ideal name ought to have:

There have been three names proposed, two on this thread, and one by @Nadrieril on Zulip: effect, color, and flavor.

Looking at these, I analyze them as follows.

Effect I think is reasonably memorable and specific, and it is the term I started with. I adopted color because (a) I have not found effect to be familiar nor approachable to people in conversation but also (b) it is not grammatically flexible. For example, the RFC frequently talks about K-colored traits, but it's not clear to me what the adjective form of an effect is (K-effected traits?). When I was using effect, I wrote K-traits, which I think is telling, since I had to drop the word.

Color is better but I see some downsides. First, it is such a common word in conversation that I can imagine it not being obvious it's a "term of art" here. It would be very hard to ever make it a keyword, if we found a reason to do so. Second, although I frequently see color used in the sense I mean it in informal conversation (typically referencing the "What color is your function" blog post), I wouldn't say it's familiar exactly. Third, color is relatively grammatically flexible, but I still found it not ideal. I wanted sometimes to talk about a specific "version" of a trait, and saying "the async color of the Trait" didn't sound right. There was also the problem that, as y'all are hopefully aware, the term "colored" has been associated with some awful parts of human history, and I found myself being careful about how I used the word color when writing the RFC to try and avoid evoking those connotations. All in all, not ideal for a word that may wind up being used a lot.

So this morning I've been pondering @Nadrieril's suggestion of flavor. It has all the advantages of color (familiar, memorable, approachable) but it seems to me to be actually more specific than color (I can imagine it becoming a keyword someday, for example, without quite the level of pain that color would have). It is also more grammatically flexible: we can talk about the flavor of a trait and that sounds very good, and it has no negative connotations. So I'm strongly considering switching the RFC to adopt flavor.

I'd be curious to hear a similar argument made in favor of effect. It's worth listing out the ways we'd like to use the term. I'll give examples alternating through the proposals and (typically) quoting from the RFC:

It's worth pointing out that whatever word we choose, it's a 2-way door. This is a name for a design pattern that we use to maintain internal consistency for the language. If we find that the name doesn't work, we can change it, and the only thing that becomes outdated is our internal design docs and blog posts. That said, I do think the word will wind up "leaking out" into how people talk about Rust, so it's worth investing some thought into it and trying to get it right the first time.

nikomatsakis commented 1 month ago

@rpjohnst

I'm thinking more about this...

My point is really that "effect" need not imply anything so deep about the language that it does not already apply to Rust, and specifically that Koka's use of CPS is not really relevant to how Rust might use the term.

I agree with you here but I still find there is something that's bugging me. I think it's this. I like being able to think of "effect" as a shorthand for "side effect". But if one of the elements of a "side effect" is that it translates your function into a coroutine -- and potentially introduces a new way for the function to take input from the outside, as in the most general case of yield returning a value -- that seems to me to go beyond being a side effect of the function, and rather a different thing altogether.

So I agree that I was overrotating on the continuation passing style implementation detail, but I think I still see value in limiting the term "effect" to "side effects".

That said, if we only think of generators emitting values (and not getting values back in as input), I can see it as a generalization of side effect that works. It just seems to go beyond my intuitions, but I could get used to it. It does explain why yield_all is a better fit as the "do" operation for a generator.

(UPDATE: I guess you can think of read as a side-effect......if you map side-effect to "any interaction with your environment" essentially....)

nikomatsakis commented 1 month ago

Responding to my response to @Diggsey, @GoldsteinE wrote...

I feel like async Read is the confusing part. Fn is already a deeply magical trait, having a magic translation to another magical trait is not that weird.

Ah, I see-- yes, I get that. I agree that the Fn trait already has a lot of supporting syntax (sugar for (), closures, etc) and so having more special-case sugar doesn't seem strange.

I think the point of the RFC is not that async Read would be a magical trait, but that there would be some way to define async-colored traits (async flavors of traits?). Exactly what that way will be is not defined. It might be as simple as letting you have two otherwise unrelated trait definitions...

trait Read {
   fn read();
}
trait async Read {
    fn poll_read(self: Pin<&mut Self>);
    fn read() -> async -> () {
        /* something defined in terms of `poll_read` */
    }
}

...or it can be some way to automatically create async-color traits from regular ones, or it could be a mix of those things. It might also be different between colors. None of that is specified in the RFC.

What the RFC is saying is that we aim for K Trait to be a bog-standard syntax that you are used to seeing on various traits, not some piece of special sugar specific to Fn.

In any case, I agree with you that this is a new thing in the RFC (and I tried to highlight it as such).

GoldsteinE commented 1 month ago

It is not obvious that having trait async Read is desirable. There’re multiple possible ways to implement AsyncRead, and having one “blessed” by the standard library like that would make other implementations (for example, provided by the async runtime) second-class in comparison. There’s a chance to get into a situation where libraries would need to have documentation like “there’s async Read syntax, which you should never use, and instead use our_rt::io::AsyncRead; and use that”.

Unless, of course, async Read is imported separately and you can just use our_rt::io::{async Read}.

I think even the ability to have async Read is a huge proposal that would need its own rationale and its own consideration. This RFC currently sets “having async Read if we have async version of Read” as a goal, and I don’t think it motivates that enough, explains it enough or considers the drawbacks of this decision.

GoldsteinE commented 1 month ago

What the RFC is saying is that we aim for K Trait to be a bog-standard syntax that you are used to seeing on various traits, not some piece of special sugar specific to Fn.

I think it’s pretty uncontroversial for const (and maybe unsafe?), but has huge implications and trade-offs for async, which are not explored in the RFC.

Diggsey commented 1 month ago

I think the point of the RFC is not that async Read would be a magical trait, but that there would be some way to define async-colored traits (async flavors of traits?). Exactly what that way will be is not defined. It might be as simple as letting you have two otherwise unrelated trait definitions...

Ok, that does explain it better. It does raise some concerns about namespacing though. If dyn Read could be a completely different trait from Read, how would I import it? Do I use std::io::dyn Read? That seems problematic. If they all share the same qualified name, then what if I want to use a different dyn Read, but still use the original Read? Who gets to define a dyn Read - do we need a new set of orphan rules?

nikomatsakis commented 1 month ago

@Diggsey You would import Read, yes, and you would write dyn async Read, just as (under RFC #3668) you write dyn async Fn().

If they all share the same qualified name, then what if I want to use a different dyn Read, but still use the original Read? Who gets to define a dyn Read - do we need a new set of orphan rules?

If you write dyn Read, you are getting the synchronous color of Read. If you write dyn async Read, you are getting the asynchronous version. The orphan rules would be the same: any place you can write an impl for Read, you can also write one for async Read.

In short, you can think of a trait like Fn (or Read) as having a default, uncolored version plus some set of K-colored alternatives (not every alternative is necessarily available for every trait). You select the colored version with the keyword.

(Bear in mind that all of this is going beyond what's specified in the RFC, which specifically avoids saying what async means when applied to any trait beyond Fn, and these questions don't arise in that particular case because Fn itself is unstable and cannot presently be implemented.)

GoldsteinE commented 1 month ago

Bear in mind that all of this is going beyond what's specified in the RFC, which specifically avoids saying what async means when applied to any trait beyond Fn

I guess it’s just weird that it says that we should have it mean something, without exploring whether there is a meaning that makes sense.

yoshuawuyts commented 1 month ago

@nikomatsakis said:

But if one of the elements of a "side effect" is that it translates your function into a coroutine -- and potentially introduces a new way for the function to take input from the outside, as in the most general case of yield returning a value -- that seems to me to go beyond being a side effect of the function, and rather a different thing altogether.

To maybe help frame this: Koka differentiates between effect types and effect handlers. Effect handlers are sometimes also referred to as typed continuations. Effect handlers in Koka are expressed in the type system as effect types. But not all effect types are also effect handlers. An example of an effect type in Koka which is not an effect handler is divergence (div in Koka).

Divergent functions in Koka are functions which do not statically guarantee they will terminate. This cannot be modeled in terms of coroutines, despite having runtime implications. Instead it directly represents a language-level capability. Daan Leijen (Koka's lead) explained this as follows:

The effect types are not just syntactic labels but they have a deep semantic connection to the program [...]. For example, we can prove that if an expression that can be typed without an exn effect, then it will never throw an unhandled exception; or if an expression can be typed without a div effect, then it always terminates

This is why I tend to think of effect types more like language-level capabilities or permissions. With typed continuations (effect handlers) representing a special subset of those which map to e.g. async/.await, gen/yield_all, and try/?.

tmandry commented 1 month ago

There are a few related concepts that I think it's helpful to differentiate between:

The term "flavor"/"color"/etc. emphasize the environment and requirements of that environment. Whereas "effect" emphasizes the capability (the operation).

For Rust we are pretty clearly committed to this direction. We named it async fn and not await fn. const and try are also examples of this.

rpjohnst commented 1 month ago

@clarfonthey

This is a small nitpick, but since this is kind of a theory-heavy RFC, it felt worth pointing out that:

(Indeed this is essentially what makes "algebraic effects" "algebraic.")

…is not true. Things can be algebraic and also order-dependent, and so it's not immediately obvious why we should adopt an order-independent approach. Sure, the example you gave was reasonable, but it's unclear whether all future versions of effects/colours will fit those descriptions.

Not to take this too much further into the weeds, but while I am aware this leaves out a lot of technical details in the general sense, in programming languages this is a common shorthand for this more specific property, applied this particular way. For example in https://arxiv.org/pdf/1807.05923, or by contrast this discussion of "scoped" effects which are not considered "algebraic:" https://arxiv.org/pdf/2304.09697.

My larger point is that, whatever you call it, this order independence is a useful property in general, used by most existing implementations of (algebraic) effects (and handlers), and not at all limited to the particular effects Rust has looked at so far. It solves a lot of practical problems with prior approaches like monad transformer stacks, and while we may indeed want to go beyond it eventually, doing so would require deeper changes to the existing state machine pattern, so we don't have any idea what that would look like yet.

@nikomatsakis

I guess you can think of read as a side-effect......if you map side-effect to "any interaction with your environment" essentially....

Indeed, "(algebraic) effects (and handlers)" is an established term with basically this meaning. I might phrase it as "anything an expression evaluation might do beyond producing a value of its type." Reading and writing mutable state, nontermination, I/O, etc. are all typically given as examples of "effects" that can be captured in an effect type system.

I do agree this usage of the term "effect" is pretty jargon-y for this audience. Even in this thread we've already got some miscommunication and imprecision as a result, and I regularly see people in the project refer to things as "effects" when they do not fit this definition. If we do decide that we want Rust to line up with the established terminology here, we'd still have to put some work into communicating exactly what we mean by it. So for that reason maybe making up something new and Rust-specific would be easier- e.g. we have the similar precedent of using "lifetime" instead of "region."

rpjohnst commented 1 month ago

More specifically...

Effect handlers are sometimes also referred to as typed continuations. Effect handlers in Koka are expressed in the type system as effect types.

This does not detract from the point you were making about div, but this is not how these terms are used in either Koka or WebAssembly. "(Typed) continuation" refers to the suspended computation itself, corresponding to Rust's state machine; "effect handler" refers to the part of the program that receives that continuation when the computation suspends and invokes the continuation to resume it, corresponding to Rust's executor; effect types determine the signature of those continuations and handlers, but critically not which specific handler must be used to drive a computation.

The term "flavor"/"color"/etc. emphasize the environment and requirements of that environment. Whereas "effect" emphasizes the capability (the operation).

For Rust we are pretty clearly committed to this direction. We named it async fn and not await fn. const and try are also examples of this.

FWIW this is not an uncommon pattern in languages that use the term "effect," either. Languages with user-defined effects often use a trait-like syntax to introduce them, with the more "environment-y" name like async (which is used in function signatures) referring to a whole group of more "operation-y" names like await (which are used in function bodies). E.g. the Effekt docs use the example of a Proc effect with yield, fork, and exit operations.

traviscross commented 1 month ago

For a Rust-encoded version of that, see the examples in the effing-mad crate. E.g.:

nikomatsakis commented 1 month ago

OK, I've pushed through some 'surface level' changes.

I plan to add a FAQ to address some of the clarifications made here.

nikomatsakis commented 1 month ago

It is not obvious that having trait async Read is desirable. There’re multiple possible ways to implement AsyncRead, and having one “blessed” by the standard library like that would make other implementations (for example, provided by the async runtime) second-class in comparison.

This is a bit orthogonal from the RFC, but not exactly, so let me say: I am not aligned here. I think having some kind of standardized trait for async Read would be a huge win for the ecosystem and for interop. I also think you should be able to use async Read much like a sync version of Read -- i.e., it should offer the same basic methods as the sync trait, even if it has other methods (for example, perhaps it is based on a poll-like method).

A big part of async Rust's appeal is that it is like sync Rust but better: you can port code over, and then you can give it async superpowers and a big part of Rust's overall appeal is that you can grab libraries off of crates.io and put them to use. Lacking standardized traits for async interop, or having async traits that don't offer the same capabilities as their sync counterparts, makes that much more difficult.

Now, where I am aligned is that I don't think it's obvious (yet) what that trait should precisely look like or whether it can be mechanically derived from Read. But whether or not we it would be good to have one? That seems pretty clear.

I think even the ability to have async Read is a huge proposal that would need its own rationale and its own consideration. This RFC currently sets “having async Read if we have async version of Read” as a goal, and I don’t think it motivates that enough, explains it enough or considers the drawbacks of this decision.

I don't know what the "ability" to have async Readmeans. We have the ability to do it now. We just need to write an RFC. That is unchanged by this current RFC. What does change with the current RFC is that we are saying "if we write that RFC to add an async flavor of the Read trait, we should call it async Read, and not AsyncRead". This will require some amount of supporting language machinery. To continue in Rust's tradition, I would eventually expect that to be some mechanism that other crates can use (so e.g. a runtime can add their own async-flavored traits if they so desire). But that generic mechanism has not been designed, and that's ok.

The motivation for this I think is fairly clear in Rust today. It comes down to:

PeterHatch commented 1 month ago

I think it is a lot less to take in than it may appear. I view this RFC as documenting existing patterns and "rounding them out" more than it is creating new ones. The only brand new thing in this RFC is committing to some syntax like async<T> or async -> T (similar to what is proposed in #3628). The rest is already part of accepted RFCs, e.g. RFC #3668 for async closures proposed the async Fn syntax. This RFC just says "we'll use the same syntax for future async traits and for const trait".

I think you're underestimating how much brand new stuff this RFC introduces. RFC #3668 is clear that the trait will be named AsyncFn, with a proposed syntax of async Fn as syntactic sugar. Changing that to the trait being named async Fn is, AFAICT, a huge change that introduces a form of overloading to Rust for the first time. It means everywhere we can refer to the name of a trait we need to either allow keywords in front of the name (UFCS would require this, I think), or limit it to only being able to specify a family of related traits and not a specific one (maybe how importing would work?). How to document it would also need to be decided. Obviously not everything needs to be decided now, but there needs to be a reason to think there will be good options, and that there's isn't anything being overlooked that'll cause problems in the future.

nikomatsakis commented 1 month ago

Ok, I've added a FAQ that clarifies that this RFC is not committing to mechanical translation and a bit of text to the intro about the "just add flavor" goal.

rpjohnst commented 1 month ago

RFC https://github.com/rust-lang/rfcs/pull/3668 is clear that the trait will be named AsyncFn, with a proposed syntax of async Fn as syntactic sugar.

No, this is not what #3668 proposed. The AsyncFn trait was included there as an explanation of perma-unstable implementation details, and async Fn was intended as the sole public/stabilizable name. This RFC is then simply extending this decision to any other (as-yet-unspecified) traits we may eventually want to give an async or other flavor.

nikomatsakis commented 1 month ago

I think you're underestimating how much brand new stuff this RFC introduces. RFC https://github.com/rust-lang/rfcs/pull/3668 is clear that the trait will be named AsyncFn, with a proposed syntax of async Fn as syntactic sugar

Actually, the AsyncFn traits are explicitly called out by RFC #3668 as an implementation detail (emphasis mine):

These traits are intended to remain unstable to name or implement just like the Fn traits. Nonetheless, we'll describe the details of these traits so as to explain the user-facing features enabled by them.

The user-facing features being described is the async Fn syntax described in the guide section. That section (quite intentionally) does not mention AsyncFn.

Now it is certainly true that we could stabilize and expose the AsyncFn trait name. I'd say there are many options

The point of this RFC is to codify that a preference for the final option -- the camel-case names don't seem right because they don't address the need for e.g. const Fn traits and other const traits (see also this FAQ); the second pattern worked for some but felt oddly inconsistent to others (see this FAQ and the motivation). The third pattern is both inconsistent and a big change for all of Rust (see this FAQ), and doesn't scale to other needs.

PeterHatch commented 1 month ago

No, this is not what #3668 proposed. The AsyncFn trait was included there as an explanation of perma-unstable implementation details, and async Fn was intended as the sole public/stabilizable name. This RFC is then simply extending this decision to any other (as-yet-unspecified) traits we may eventually want to give an async or other flavor.

Fair enough, I didn't read it closely enough. Still, it's not like that RFC explored all the implications of this kind of name overloading in the general case, and I do think the implications should be explored before committing to it.

nikomatsakis commented 1 month ago

Hmm, so I agree that the RFC doesn't lay out all the details of how async $Trait will work for other traits (or other keywords). I think there are two areas where the RFC is kind of saying "this is the syntactic direction we want to adopt, now we have to figure out the details in future work"...

I think there are some elements of that last part that are clear, for example that one just imports and refers to the trait name. i.e., async Foo is a flavor of some trait Foo, so you would "use" the name Foo, not async Foo.

(I would add that in case we find this really just doesn't work, it's not like we can't back off from it, it will simply mean that async Fn is "different" -- and perhaps we deprecate that syntax for the new convention we prefer.)

EDIT: I extended the unanswered questions here to make that clearer, although this isn't really the purpose I think one should have for unanswered questions.

GoldsteinE commented 1 month ago

I don't know what the "ability" to have async Readmeans.

It means a language mechanism that allows defining async versions of a sync trait that applies wider than already-magical Fn* traits.

To use AsyncRead as an example: there’re at least two fundamentally different ways an async Read trait could deal with buffer lifetimes. One way is to borrow the buffer only for the duration of the .poll() method, the other is to pass it to the .read() -> impl Future by value and get back when .poll() returns Ready (see monoio::io::AsyncReadRent). The first one is natural for poll-based async, the second can be more natural for completion-based async. By giving a special syntax to one of the versions (probably first one, since most of the current Rust async ecosystem is poll-based), the second one becomes forever second-class.

Special syntax for flavours makes sense when there’s an obvious one-to-one correspondence between flavoured and unflavoured variants. That’s true for const and unsafe, but that’s not true for async. By commiting to giving AsyncRead a special syntax, we commit to choosing one of the flavoured variants and implicitly encouraging it at expense of the other. That’s a drawback of having a special syntax for AsyncRead and any other trait that can have multiple incompatible definitions, which is not mentioned or in any way explored in the RFC text.

I argue that there can be value in having AsyncRead in std (so it’s easier to make runtime-agnostic crates), but not having a special syntax for it, so we can add other versions in the future without them feeling second-class.

nikomatsakis commented 1 month ago

@GoldsteinE OK, thanks for explaining, that helps me understand! So you're saying that having the idea that we'll have one privileged "async flavor of the sync Read trait" is new here. I tend to think that's inevitable, though. Even if it were called AsyncRead, it'd be the "privileged counterpart" (but indeed less so than async Read). I think it's fine if there is async Read which is the naive counterpart to Read and then more optimized interfaces you can use (though I am not sure if that's truly necessary).

It is also true that nothing in this RFC says we have to have an async flavor of Read. But it does say that, if we do so, we should spell it async Read, so that it follows the common convention (and yes, I do tend to think we ought to, and the RFC acknowledges that; this is because I think we should try to make async and sync Rust as parallel as possible -- but no more so).

(Edit: Restructured lightly.)

clarfonthey commented 1 month ago

Gonna be honest, I hadn't checked this thread for a few days, and part of the reason is I don't really feel like my feedback is going to affect the decisions, and that kind of feels reflected in the decision to rename "colour" to "flavour". (I know you're spelling it different. I don't think it matters enough to change my typing habits.)

This genuinely solves none of the initial issues I had with colour, and just moves them into an equally opaque jargon term. Whether I see the colour of a function or taste its flavour doesn't really tell me much about it, since functions are abstract objects without an appearance or taste.

Yes, colour and flavour are analogues for other properties, but then, why not call them properties? Because they have other properties that aren't capital P Properties? What makes those other properties different enough to not be considered colours or flavours? What makes the error-handling capabilities of Result special enough to make it a capital R Result, and not just any old result?

The point here is that yes, jargon still has to be learned. But I prefer jargon to at least be directly applicable to what they describe once learned, and not remembered via weird riddles and metaphors. It just makes the language harder to understand.

I mentioned "affect" before as being different enough from "effect" to shed its preconceptions while still being accurate (they do affect the function somehow), but it wasn't mentioned in any of the options. I do think that's worth considering if the downsides of effect are too great to consider. And no matter what we do, any documentation describing what we implement is probably going to reference all of these terms anyway, just to establish common ground.

GoldsteinE commented 1 month ago

@nikomatsakis Basically my argument is that async Read is somewhat more parallel than actually possible: it’s not “the async version of Read”, merely “an async version of Read”, which I don’t feel is good enough to warrant calling it async Read.

I’m especially concerned with the perspective of the ecosystem moving to e.g. completion-based version or just version that deals slightly differently with buffers (note that AsyncRead in futures is different from AsyncRead in tokio). I feel like guidance “write use coolasync::io::AsyncRead instead of use std::io::AsyncRead” is much less intrusive than “write use coolasync::io::AsyncRead instead of async Read”.

Anyway, I think that I explained my point by now, and I don’t think I can contribute more to this discussion. Thanks for taking the time to consider it.

nikomatsakis commented 1 month ago

@GoldsteinE I appreciate you raising the point and helping me to understand it. I'm going to be writing up a summary for an FAQ and bringing it to the broader lang team as part of our review from this thread. I'll drop a note here with a link so you can double check I've accurately reflected your opinion. (Same for you, @clarfonthey)

ssokolow commented 1 month ago

multi-colored blocks does not seem worthwhile. K9 { K7 { K8 { $expr } } } is clearer than K4 K6 K5 { $expr } in emphasizing the application order.

However, given current rustfmt convention, that would get rendered as this:

K9 {
    K7 {
        K8 {
            $expr
        }
    }
}

...which I don't find reasonable. I don't think it's a good idea to encourage either more #[rustfmt::skip] or a glaring inconsistency in how rustfmt handles blocks... especially when you may have to bump the #[rustfmt::skip] up in the token tree to avoid a "can't use that there" error.

tmandry commented 1 month ago

For async Read/Write traits, I consider the current state-of-the-art to be this proposal by @nrc. It proposes generic Read and Write traits that look like async versions of the existing traits, plus specialized "Ready" and "Owned" subtraits of each that specialize for readiness and completion-based I/O respectively.

The Read and Write traits in the proposal would be called async std::io::Read and async std::io::Write under this RFC. It's not crystal clear to me if ReadyRead and OwnedRead would be async ReadyRead and async OwnedRead or if those traits are async-only. I think they both have obvious equivalent blocking semantics, but I can only imagine wanting those semantics in the Ready case, not the Owned case.

This RFC doesn't need to answer all of those questions, but I think it's important to consider that some traits will have sync and async versions while some will only have one or the other. For some, it may not be clear at the time of stabilizing whether we will want both. This only complicates naming under this RFC if it's an async-first trait. I feel pretty confident that we can come up with a way of resolving such uncertainties, both because I think it should be pretty clear by the time of stabilizing whether we will want both, and because for any traits where it's not completely clear, we can commit to stabilizing an explicit async flavor.

clarfonthey commented 1 month ago

multi-colored blocks does not seem worthwhile. K9 { K7 { K8 { $expr } } } is clearer than K4 K6 K5 { $expr } in emphasizing the application order.

However, given current rustfmt convention, that would get rendered as this:

K9 {
    K7 {
        K8 {
            $expr
        }
    }
}

...which I don't find reasonable. I don't think it's a good idea to encourage either more #[rustfmt::skip] or a glaring inconsistency in how rustfmt handles blocks... especially when you may have to bump the #[rustfmt::skip] up in the token tree to avoid a "can't use that there" error.

I mean, given how we have a predefined ordering for the keywords, it would make senses that, for example:

const {
    unsafe {
        expr()
    }
}

could be shortened to:

const unsafe {
    expr()
}

if needed.

PeterHatch commented 1 month ago

So I disagree with pretty much all the reasons for not going with AsyncFn, and wanted to go over why, for each of them.

First, the story of how to transition something from sync to async gets more complicated. It's not a story of "just add async keywords in the right places".

I think that story is only simpler at that high a level of abstraction. If you know what the right places are, you know why they need to change. If you know you want a different version of a trait, changing the name is what you do every other time that's what you need, so that seems simpler to me. If you don't know you want a different version of a trait, just adding a keyword means you still may not understand what the change did, where changing the name is extremely clear.

Also, even if that's the story we want, it's not going to be the case for any traits outside the standard library. Consistency across the ecosystem seems important; if we eventually give the rest of the ecosystem the tools to change and encourage them to do so, we can change the standard library then as well.

Second, this convention does not offer an obvious way to support const traits like const Default, unless we are going to produce ConstDefault variants as well somehow.

Absolutely right. AsyncTrait is the obvious solution for async traits, and doesn't work for const. I don't think that means we need some other consistent method, I think it means those are fundamentally different things and treating them as the same is a false equivalency that would make the language harder to learn.

And if there are more variants in the future, e.g., AsyncSendSomeTrait or AsyncTrySendSomeTrait, it becomes very unwieldy.

Keywords would also become unwieldy in this case. Only differences I can see is that keywords might not require a specific order, and that long names benefit from auto-completion and the ability to import as a shorter name.

Third, although we are not committing to any form of "flavor generics" in this RFC, we would also prefer not to close the door entirely; using an entirely distinct identifier like AsyncFn would make it very difficult to imagine how one function definition could be made generic over a flavor.

You just have AsyncFn be an alias for the generic version with the async parameter true. It's not at all clear to me that that wouldn't be desirable in general - being generic over async is extra complexity that is pretty often unwanted. My biggest concern with the idea of flavor generics is that they could make learning the language, and the standard library in particular, more complicated for everyone. I don't think committing to the extra complexity in the standard library even before we've committed to flavor generics is the way to go.

For the last point, and just in general, I consider keywords to be quite complicated syntax; since they can be used for pretty much anything, you have to specifically learn what they mean in each case. If there's a question of using a keyword or some other method, I lean towards the other method.

nikomatsakis commented 1 month ago

The lang team will be discussing this RFC in a design meeting -- I think today but I might be wrong -- and so I prepared a summary of the discussion here:

https://hackmd.io/w2-etPU7RouzxJ6GJ1fbSg

Feedback welcome. @PeterHatch I hadn't seen your comment yet so I'm going to read it over and make sure it's reflected in there. Some sections to note...

I also took another stab at the RFC summary and framing that I'd be curious to get people's read on (is it clearer?):

The primary goal of this RFC is to commit to using async Fn as the syntax for async closures (as proposed in [RFC #3668][]) and to adding some form of syntax for 'async flavored types' similar to what is proposed in [RFC #3628][]. This "async flavored type" notation, currently denoted with the placeholder syntax 🏠async<$ty>, would be equivalent to impl Future<Output = T> (and hence its precise meaning would be dependenent on where it appears, just as impl Future can expand in different ways).

The motivation for comitting to both async Fn and 🏠async<$ty> syntaxes together is to create a coherent design pattern around async that allows the user to understand all aspects of the "async flavor" of Rust as a unit. The goal is that taking sync code and making it async can be done by adding "async" and "await" in the appropriate places without hitting places where other abstractions "leak through".

The second goal of this RFC is to make strong suggestions for the syntax of future features that are under consideration. Based on async, the RFC defines a flavor design pattern. The flavor design pattern is a series of syntax recommendations and transformations that can be applied to any "flavor keyword" K. A flavor keyword K is some keywordthat can be applied to a function or a block, like K fn foo() or K { /* something */ }, and which has the "infectious" property, meaning that code with flavor K interacts naturally with other code of the same flavor, but only in limited ways with code of other flavors. Beyond async, other examples of existing flavor keywords are const and unsafe. (There are also other "flavor-like" things in Rust that do not have keywords, like functions that operate on &T/&mut T/T; these are not described by this RFC directly.)

Based on the flavor design pattern, the RFC recommends that we use const $Trait as the syntax to indicate a version of $Trait in which some or all members are made const-flavored and that we use async $Trait as the syntax for future "async flavors" of traits that we may decide to add (e.g., async Read). The RFC does NOT define any such traits nor propose any kind of means to define them, mechanical or otherwise; the only criteria is that K $Trait should have a superet of the members of $Trait but where some have been made K-flavored (async Fn as defined in [RFC #3668] meets this definition). The RFC includes a "future possibilities" section that describe various things we could do, such as what const $Trait might mean, how unsafe Trait could work (and might not), and the possibility of "flavor generics" (aka, effect generics or keyword generics). None of those features are proposed in this RFC.

Please feel free to leave hackmd comments in the doc.

PeterHatch commented 1 month ago

So, to add context to my earlier comments, somewhat discussed under "Non-mechanical mechanisms" in the HackMD - I think the issue was that I wasn't clear about the steps that got me to my opinion, and assumed they were shared. To be specific, I think:

1) If we're committing to a syntax for consistency reasons, that consistency needs to be shared across the whole Rust ecosystem, not just the standard library, so it needs a mechanical way to be specified in Rust code. 2) Given 1, and that we're not committing to flavor generics, we need to know there is some other way to accomplish the task. 3) And after those two points, we get to the actual argument I tried to make, that this design space isn't explored enough for us to commit to it. I could continue arguing this point, but I'm not sure this is actually the source of any disagreement.

rpjohnst commented 1 month ago

On the section of the meeting notes about try and Iterator/Gen: the distinction you're running into here is that Iterator (or Gen) is already itself a "flavor carrier," like Future, and unlike e.g. Read/Default/Clone/Into.

It doesn't make sense to apply another flavor to a flavor-carrying trait, because it mixes registers or levels of abstraction. This is the exact same thing that has been thoroughly discussed in terms of streams. It is also something that RFC https://github.com/rust-lang/rfcs/pull/3628 might address- you can freely add/remove flavors to something like try gen T, without burdening the flavor carriers themselves with this awkward layer-mixing, because the try gen T syntax can lower to the appropriate multi-flavor carrying trait instead.

(Edit, reading further: To be clear, this is not unique to async gen. Fundamentally any combination of early-exit-with-possible-resume flavors has this property. There should be no difference between try async and async try, because the function/block itself will always be able to freely intermix ?s and .awaits. If you do actually mean to have a return type like Result<impl Future>, that can be obtained without any interaction with this flavor-combination system.)

Darksonn commented 1 month ago

For async Read/Write traits, I consider the current state-of-the-art to be this proposal by @nrc. It proposes generic Read and Write traits that look like async versions of the existing traits, plus specialized "Ready" and "Owned" subtraits of each that specialize for readiness and completion-based I/O respectively.

Sorry for being late to this discussion. I just saw these traits, and I think they're way inferior to the current poll-based designs used by Tokio or futures. The problem is that you're making async Read and async Write incompatible with each other. If read takes a mutable reference to self, then you cannot perform any writes while the read is ongoing, and vice-versa. Not being able to read and write at the same time is unworkable.

The way this is worked around in std is by implementing Read and Write on shared references, so we have &TcpStream and &File being readable. The problem is that you wanted to go from "1 reader or 1 writer" to "1 reader and 1 writer", but you ended up at "N readers and N writers". Implementing IO resources that support "N readers" or "N writers" is way more difficult than supporting "1 reader and 1 writer". It's so bad that the only correct implementation I've ever seen of this is the one in Tokio. The only way to do it without allocating memory involves intrusive linked lists.

Now, you might argue that the Ready trait does support concurrent reads and writes. Well, for one, you just gave up support for the completion based traits. But there's a more important issue: cancellation safety. Using Ready to perform concurrent reads and writes absolutely requires that you use it with tokio::select! or equivalent and that you sometimes cancel the ready future. This makes manually implementing the Ready trait really difficult since you have to be careful to write cancel safe code.

Even if you add "ready must be cancel safe" to the contract of Ready, there's yet another issue: Performing IO with a combined ready method that combines waiting for both reading and writing into a single function is even more inconvenient than writing poll_* functions. You basically are forced to architect your code around a massive tokio::select!. To see this for yourself, I challenge you to write a correct copy_bidirectional using these traits.

Edit: This is probably off-topic. Please reply to this comment here instead.