keyword generics initiative

initiative status: active

What is this?

This page tracks the work of the keyword generics initiative! To learn more about what we are trying to do, and to find out the people who are doing it, take a look at the charter.

Current status

The following table lists of the stages of an initiative, along with links to the artifacts that will be produced during that stage.

StageStateArtifact(s)
Proposalβœ…Proposal issue
βœ…Charter
Experimentalβœ…Evaluation
DevelopmentπŸ¦€Draft RFCs
πŸ¦€Tracking issue
Feature completeπŸ’€Stabilization report
StabilizedπŸ’€

Key:

  • βœ… -- phase complete
  • πŸ¦€ -- phase in progress
  • πŸ’€ -- phase not started yet

How Can I Get Involved?

  • Check for 'help wanted' issues on this repository!
  • If you would like to help with development, please contact the owner to find out if there are things that need doing.
  • If you would like to help with the design, check the list of active design questions first.
  • If you have questions about the design, you can file an issue, but be sure to check the FAQ or the design-questions first to see if there is already something that covers your topic.
  • If you are using the feature and would like to provide feedback about your experiences, please [open a "experience report" issue].
  • If you are using the feature and would like to report a bug, please open a regular issue.

We also participate on Zulip, feel free to introduce yourself over there and ask us any questions you have.

Building Documentation

This repository is also an mdbook project. You can view and build it using the following command.

mdbook serve

✏️ Updates

Lang-team initiatives give monthly updates. This section collects the updates from this initiative for posterity.

To add a new update:

  • Create a new file updates/YYYY-mmm.md, e.g. updates/2021-nov.md
  • Link it from the SUMMARY.md

Announcing the Keyword Generics Initiative"

This post was originally posted on the Inside Rust Blog, but is included in this repository to be more easily referenced.

We (Oli, Niko, and Yosh) are excited to announce the start of the Keyword Generics Initiative, a new initiative 1 under the purview of the language team. We're officially just a few weeks old now, and in this post we want to briefly share why we've started this initiative, and share some insight on what we're about.

1

Rust governance terminology can sometimes get confusing. An "initiative" in Rust parlance is different from a "working group" or "team". Initiatives are intentionally limited: they exist to explore, design, and implement specific pieces of work - and once that work comes to a close, the initiative will wind back down. This is different from, say, the lang team - which essentially carries a 'static lifetime - and whose work does not have a clearly defined end.

A missing kind of generic

One of Rust's defining features is the ability to write functions which are generic over their input types. That allows us to write a function once, leaving it up to the compiler to generate the right implementations for us.

Rust allows you to be generic over types - it does not allow you to be generic over other things that are usually specified by keywords. For example, whether a function is async, whether a function can fail or not, whether a function is const or not, etc.

The post "What color is your function" 2 describes what happens when a language introduces async functions, but with no way to be generic over them:

I will take async-await over bare callbacks or futures any day of the week. But we’re lying to ourselves if we think all of our troubles are gone. As soon as you start trying to write higher-order functions, or reuse code, you’re right back to realizing color is still there, bleeding all over your codebase.

This isn't just limited to async though, it applies to all modifier keywords - including ones we may define in the future. So we're looking to fill that gap by exploring something we call "keyword generics" 3: the ability to be generic over keywords such as const and async.

2

R. Nystrom, β€œWhat Color is Your Function?,” Feb. 01, 2015. https://journal.stuffwithstuff.com/2015/02/01/what-color-is-your-function/ (accessed Apr. 06, 2022).

3

The longer, more specific name would be: "keyword modifier generics". We've tried calling it that, but it's a bit of a mouthful. So we're just sticking with "keyword generics" for now, even if the name for this feature may end up being called something more specific in the reference and documentation.

To give you a quick taste of what we're working on, this is roughly how we imagine you may be able to write a function which is generic over "asyncness" in the future:

Please note that this syntax is entirely made up, just so we can use something in examples. Before we can work on syntax we need to finalize the semantics, and we're not there yet. This means the syntax will likely be subject to change over time.


#![allow(unused)]
fn main() {
async<A> trait Read {
    async<A> fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    async<A> fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

/// Read from a reader into a string.
async<A> fn read_to_string(reader: &mut impl Read * A) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    string
}
}

This function introduces a "keyword generic" parameter into the function of A. You can think of this as a flag which indicates whether the function is being compiled in an async context or not. The parameter A is forwarded to the impl Read, making that conditional on "asyncness" as well.

In the function body you can see a .await call. Because the .await keyword marks cancellation sites we unfortunately can't just infer them 4. Instead we require them to be written for when the code is compiled in async mode, but are essentially reduced to a no-op in non-async mode.

4

No really, we can't just infer them - and it may not be as simple as omitting all .await calls either. The Async WG is working through the full spectrum of cancellation sites, async drop, and more. But for now we're working under the assumption that .await will remain relevant going forward. And even in the off chance that it isn't, fallibility has similar requirements at the call site as async does.

We still have lots of details left to figure out, but we hope this at least shows the general feel of what we're going for.

A peek at the past: horrors before const

Rust didn't always have const fn as part of the language. A long long long long long time ago (2018) we had to write a regular function for runtime computations and associated const of generic type logic for compile-time computations. As an example, to add the number 1 to a constant provided to you, you had to write (playground):


#![allow(unused)]
fn main() {
trait Const<T> {
    const VAL: T;
}

/// `42` as a "const" (type) generic:
struct FourtyTwo;
impl Const<i32> for FourtyTwo {
    const VAL: i32 = 42;
}

/// `C` -> `C + 1` operation:
struct AddOne<C: Const<i32>>(C);
impl<C: Const<i32>> Const<i32> for AddOne<C> {
    const VAL: i32 = C::VAL + 1;
}

AddOne::<FourtyTwo>::VAL
}

Today this is as easy as writing a const fn:


#![allow(unused)]
fn main() {
const fn add_one(i: i32) -> i32 {
    i + 1
}

add_one(42)
}

The interesting part here is that you can also just call this function in runtime code, which means the implementation is shared between both const (CTFE5) and non-const (runtime) contexts.

5

CTFE stands for "Compile Time Function Execution": const functions can be evaluated during compilation, which is implemented using a Rust interpreter (miri).

Memories of the present: async today

People write duplicate code for async/non-async with the only difference being the async keyword. A good example of that code today is async-std, which duplicates and translates a large part of the stdlib's API surface to be async 6. And because the Async WG has made it an explicit goal to bring async Rust up to par with non-async Rust, the issue of code duplication is particularly relevant for the Async WG as well. Nobody on the Async WG seems particularly keen on proposing we add a second instance of just about every API currently in the stdlib.

6

Some limitations in async-std apply: async Rust is missing async Drop, async traits, and async closures. So not all APIs could be duplicated. Also async-std explicitly didn't reimplement any of the collection APIs to be async-aware, which means users are subject to the "sandwich problem". The purpose of async-std was to be a proving ground to test whether creating an async mirror of the stdlib would be possible: and it's proven that it is, as far as was possible with missing language features.

We're in a similar situation with async today as const was prior to 2018. Duplicating entire interfaces and wrapping them in block_on calls is the approach taken by e.g. the mongodb [async, non-async], postgres [async, non-async], and reqwest [async, non-async] crates:


#![allow(unused)]
fn main() {
// Async functionality like this would typically be exposed from a crate "foo":
async fn bar() -> Bar { 
    // async implementation goes here
}
}

#![allow(unused)]
fn main() {
// And a sync counterpart would typically be exposed from a crate
// named "blocking_foo" or a submodule on the original crate as
// "foo::blocking". This wraps the async code in a `block_on` call:
fn bar() -> Bar {
    futures::executor::block_on(foo::bar())
}
}

This situation is not ideal. Instead of using the host's synchronous syscalls, we're now going through an async runtime to get the same results - something which is often not zero-cost. But more importantly, it's rather hard to keep both a sync and async API version of the same crate in, err, sync with each other. Without automation it's really easy for the two APIs to get out of sync, leading to mismatched functionality.

The ecosystem has come up with some solutions to this, perhaps most notably the proc-macro based maybe-async crate. Instead of writing two separate copies of foo, it generates a sync and async variant for you:


#![allow(unused)]
fn main() {
#[maybe_async]
async fn foo() -> Bar { ... }
}

While being useful, the macro has clear limitations with respect to diagnostics and ergonomics. That's absolutely not an issue with the crate, but an inherent property of the problem it's trying to solve. Implementing a way to be generic over the async keyword is something which will affect the language in many ways, and a type system + compiler will be better equipped to handle it than proc macros reasonably can.

A taste of trouble: the sandwich problem

A pervasive issue in existing Rust is the sandwich problem. It occurs when a type passed into an operation wants to perform control flow not supported by the type it's passed into. Thus creating a sandwich 7 The classic example is a map operation:

7

Not to be confused with the higher-order sandwich dilemma which is when you look at the sandwich problem and attempt to determine whether the sandwich is two slices of bread with a topping in between, or two toppings with a slice of bread in between. Imo the operation part of the problem feels more bready, but that would make for a weird-looking sandwich. Ergo: sandwich dilemma. (yes, you can ignore all of this.)


#![allow(unused)]
fn main() {
enum Option<T> {
    Some(T),
    None,
}

impl<T> Option<T> {
    fn map<J>(self, f: impl FnOnce(T) -> J) -> Option<J> { ... }
}

my_option.map(|x| x.await)
}

This will produce a compiler error: the closure f is not an async context, so .await cannot be used within it. And we can't just convert the closure to be async either, since fn map doesn't know how to call async functions. In order to solve this issue, we could provide a new async_map method which does provide an async closure. But we may want to repeat those for more effects, and that would result in a combinatorial explosion of effects. Take for example "can fail" and "can be async":

not asyncasync
infalliblefn mapfn async_map
falliblefn try_mapfn async_try_map

That's a lot of API surface for just a single method, and that problem multiplies across the entire API surface in the stdlib. We expect that once we start applying "keyword generics" to traits, we will be able to solve the sandwich problem. The type f would be marked generic over a set of effects, and the compiler would choose the right variant during compilation.

Affecting all effects

Both const and async share a very similar issue, and we expect that other "effects" will face the same issue. "fallibility" is particularly on our mind here, but it isn't the only effect. In order for the language to feel consistent we need consistent solutions.

FAQ

Q: Is there an RFC available to read?

Rust initiatives are intended for exploration. The announcement of the Keyword Generics Initiative marks the start of the exploration process. Part of exploring is not knowing what the outcomes will be. Right now we're in the "pre-RFC" phase of design. What we hope we'll achieve is to enumerate the full problem space, design space, find a balanced solution, and eventually summarize that in the form of an RFC. Then after the RFC is accepted: implement it on nightly, work out the kinks, and eventually move to stabilize. But we may at any point during this process conclude that this initiative is actually infeasible and start ramping down.

But while we can't make any assurances about the outcome of the initiative, what we can share is that we're pretty optimistic about the initiative overall. We wouldn't be investing the time we are on this if we didn't think we'd be actually be able to see it through to completion.

Q: Will this make the language more complicated?

The goal of keyword generics is not to minimize the complexity of the Rust programming language, but to minimize the complexity of programming in Rust. These two might sound similar, but they're not. Our reasoning here is that by adding a feature, we will actually be able to significantly reduce the surface area of the stdlib, crates.io libraries, and user code - leading to a more streamlined user experience.

Choosing between sync or async code is a fundamental choice which needs to be made. This is complexity which cannot be avoided, and which needs to exist somewhere. Currently in Rust that complexity is thrust entirely on users of Rust, making them responsible for choosing whether their code should support async Rust or not. But other languages have made diferent choices. For example Go doesn't distinguish between "sync" and "async" code, and has a runtime which is able to remove that distinction.

In today's Rust application authors choose whether their application will be sync or async, and even after the introduction of keyword generics we don't really expect that to change. All generics eventually need to have their types known, and keyword generics are no different. What we're targeting is the choice made by library authors whether their library supports is sync or async. With keyword generics library authors will be able to support both with the help of the compiler, and leave it up to application authors to decide how they want to compile their code.

Q: Are you building an effect system?

The short answer is: kind of, but not really. "Effect systems" or "algebraic effect systems" generally have a lot of surface area. A common example of what effects allow you to do is implement your own try/catch mechanism. What we're working on is intentionally limited to built-in keywords only, and wouldn't allow you to implement anything like that at all.

What we do share with effect systems is that we're integrating modifier keywords more directly into the type system. Modifier keywords like async are often referred to as "effects", so being able to be conditional over them in composable ways effectively gives us an "effect algebra". But that's very different from "generalized effect systems" in other languages.

Q: Are you looking at other keywords beyond async and const?

For a while we were referring to the initiative as "modifier generics" or "modifier keyword generics", but it never really stuck. We're only really interested in keywords which modify how types work. Right now this is const and async because that's what's most relevant for the const-generics WG and async WG. But we're designing the feature with other keywords in mind as well.

The one most at the top of our mind is a future keyword for fallibility. There is talk about introducing try fn() {} or fn () -> throws syntax. This could make it so methods such as Iterator::filter would be able to use ? to break out of the closure and short-circuit iteration.

Our main motivation for this feature is that without it, it's easy for Rust to start to feel disjointed. We sometimes joke that Rust is actually 3-5 languages in a trenchcoat. Between const rust, fallible rust, async rust, unsafe rust - it can be easy for common APIs to only be available in one variant of the language, but not in others. We hope that with this feature we can start to systematically close those gaps, leading to a more consistent Rust experience for all Rust users.

Q: What will the backwards compatibility story be like?

Rust has pretty strict backwards-compatibility guarantees, and any feature we implement needs to adhere to this. Luckily we have some wiggle room because of the edition mechanism, but our goal is to shoot for maximal backwards compat. We have some ideas of how we're going to make this work though, and we're cautiously optimistic we might actually be able to pull this off.

But to be frank: this is by far one of the hardest aspects of this feature, and we're lucky that we're not designing any of this just by ourselves, but have the support of the language team as well.

Q: Aren't implementations sometimes fundamentally different?

Const Rust can't make any assumptions about the host it runs on, so it can't do anything platform-specific. This includes using more efficient instructions of system calls which are only available in one platform but not another. In order to work around this, the const_eval_select intrinsic in the standard library enables const code to detect whether it's executing during CTFE or runtime, and execute different code based on that.

For async we expect to be able to add a similar intrinsic, allowing library authors to detect whether code is being compiled as sync or async, and do something different based on that. This includes: using internal concurrency, or switching to a different set of system calls. We're not sure whether an intrinsic is the right choice for this though; we may want to provide a more ergonomic API for this instead. But because keyword generics is being designed as a consistent feature, we expect that whatever we end up going with can be used consistently by all modifier keywords.

Conclusion

In this post we've introduced the new keyword generics initiative, explained why it exists, and shown a brief example of what it might look like in the future.

The initiative is active on the Rust Zulip under t-lang/keyword-generics - if this seems interesting to you, please pop by!

Thanks to everyone who's helped review this post, but in particular: fee1-dead, Daniel Henry-Mantilla, and Ryan Levick

Progress Report February 2023

This post was originally posted on the Inside Rust Blog, but is included in this repository to be more easily referenced.

About 9 months ago we announced the creation of the Keyword Generics Initiative; a group working under the lang team with the intent to solve the function coloring problem 1 through the type system not just for async, but for const and all current and future function modifier keywords as well.

We're happy to share that we've made a lot of progress over these last several months, and we're finally ready to start putting some of our designs forward through RFCs. Because it's been a while since our last update, and because we're excited to share what we've been working on, in this post we'll be going over some of the things we're planning to propose.

1

To briefly recap this problem: you can't call an async fn from a non-async fn. This makes the "async" notation go viral, as every function that calls it also needs to be async. But we believe possibly more importantly: it requires a duplication of most stdlib types and ecosystem libraries. Instead we suspected we might be able to overcome this issue by introducing a new kind of generic which would enable functions and types to be "generic" over whether they're async or not, const or not, etc.

An async example

In our previous post we introduced the placeholder async<A> syntax to describe the concept of a "function which is generic over its asyncness". We always knew we wanted something that felt lighter weight than that, so in for our current design we've chosen to drop the notion of a generic parameter for the end-user syntax, and instead picked the ?async notation. We've borrowed this from the trait system, where for example + ?Sized indicates that something may or may not implement the Sized trait. Similarly ?async means a function may or may not be async. We also refer to these as "maybe-async" functions.

Time for an example. Say we took the Read trait and the read_to_string_methods. In the stdlib their implementations look somewhat like this today:


#![allow(unused)]
fn main() {
trait Read {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

/// Read from a reader into a string.
fn read_to_string(reader: &mut impl Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string)?;
    Ok(string)
}
}

Now, what if we wanted to make these async in the future? Using ?async notation we could change them to look like this:


#![allow(unused)]
fn main() {
trait ?async Read {
    ?async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?async fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}

/// Read from a reader into a string.
?async fn read_to_string(reader: &mut impl ?async Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}
}

The way this would work is that Read and read_to_string would become generic over their "asyncness". When compiled for an async context, they will behave asynchronously. When compiled in a non-async context, they will behave synchronously. The .await in the read_to_string function body is necessary to mark the cancellation point in case the function is compiled as async; but when not async would essentially become a no-op 2:

2

One restriction ?async contexts have is that they can only call other ?async and non-async functions. Because if we could call an always-async function, there would be no clear right thing to do when compiled in non-async mode. So things like async concurrency operations won't directly work in always-async contexts. But we have a way out of this we talk about later in the post: if is_async() .. else ... This allows you to branch the body of a ?async fn based on which mode it's being compiled in, and will allow you to write different logic for async and non-async modes. This means you can choose to use async concurrency in the async version, but keep things sequential in the non-async version.


#![allow(unused)]
fn main() {
// `read_to_string` is inferred to be `!async` because
// we didn't `.await` it, nor expected a future of any kind.
#[test]
fn sync_call() {
    let _string = read_to_string("file.txt")?;
}

// `read_to_string` is inferred to be `async` because
// we `.await`ed it.
#[async_std::test]
async fn async_call() {
    let _string = read_to_string("file.txt").await?;
}
}

We expect ?async notation would be most useful for library code which doesn't do anything particularly specific to async Rust. Think: most of the stdlib, and ecosystem libraries such as parsers, encoders, and drivers. We expect most applications to choose to be compiled either as async or non-async, making them mostly a consumer of ?async APIs.

A const example

A main driver of the keywords generics initiative has been our desire to make the different modifier keywords in Rust feel consistent with one another. Both the const WG and the async WG were thinking about introducing keyword-traits at the same time, and we figured we should probably start talking with each other to make sure that what we were going to introduce felt like it was part of the same language - and could be extended to support more keywords in the future.

So with that in mind, it may be unsurprising that for the maybe-const trait bounds and declarations we're going to propose using the ?const notation. A common source of confusion with const fn is that it actually doesn't guarantee compile-time execution; it only means that it's possible to evaluate in a const compile-time context. So in a way const fn has always been a way of declaring a "maybe-const" function, and there isn't a way to declare an "always-const" function. More on that later in this post.

Taking the Read example we used earlier, we could imagine a "maybe-const" version of the Read trait to look very similar:


#![allow(unused)]
fn main() {
trait ?const Read {
    ?const fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?const fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { ... }
}
}

Which we could then use use as a bound in the const read_to_string function, like this:


#![allow(unused)]
fn main() {
const fn read_to_string(reader: &mut impl ?const Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string)?;
    Ok(string)
}
}

Just like with ?async traits, ?const traits would also need to be labeled as ?const when used as a bound. This is important to surface at the trait level, because it's allowed to pass non-const bounds to maybe-const functions, as long as no trait methods are called in the function body. This means we need to distinguish between "never-const" and "maybe-const".

You may have noticed the ?const on the trait declaration and the extra ?const on the trait methods. This is on purpose: it keeps the path open to potentially add support for "always-const" or "never-const" methods on traits as well. In ?async we know that even if the entire trait is ?async, some methods (such as Iterator::size_hint) will never be async. And this would make ?const and ?async traits behave similarly using the same rules.

Combining const and async

We've covered ?async, and we've covered ?const. Now what happens if we were to use them together? Let's take a look at what the Read trait would look like when if we extended it using our designs for ?const and ?async:


#![allow(unused)]
fn main() {
trait ?const ?async Read {
    ?const ?async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?const ?async fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}

/// Read from a reader into a string.
const ?async fn read_to_string(reader: &mut impl ?const ?async Read) -> io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}
}

That's sure starting to feel like a lot of keywords, right? We've accurately described exactly what's going on, but there's a lot of repetition. We know that if we're dealing with a const ?async fn, most arguments probably will want to be ?const ?async. But under the syntax rules we've proposed so far, you'd end up repeating that everywhere. And it probably gets worse once we start adding in more keywords. Not ideal!

So we're very eager to make sure that we find a solution to this. And we've been thinking about a way we could get out of this, which we've been calling effect/.do-notation. This would allow you to mark a function as "generic over all modifier keywords" by annotating it as effect fn, and it would allow the compiler to insert all the right .await, ?, and yield keywords in the function body by suffixing function calls with .do.

Just to set some expectations: this is the least developed part of our proposal, and we don't intend to formally propose this until after we're done with some of the other proposals. But we think it's an important part of the entire vision, so we wanted to make sure we shared it here. And with that out of the way, here's the same example we had above, but this time using the effect/.do-notation:


#![allow(unused)]
fn main() {
trait ?effect Read {
    ?effect fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    ?effect fn read_to_string(&mut self, buf: &mut String) -> Result<usize> { .. }
}

/// Read from a reader into a string.
?effect fn read_to_string(reader: &mut impl ?effect Read) -> std::io::Result<String> {
    let mut string = String::new();
    reader.read_to_string(&mut string).do;  // note the singular `.do` here
    string
}
}

One of the things we would like to figure out as part of effect/.do is a way to enable writing conditional effect-bounds. For example: there may be a function which is always async, may never panic, and is generic over the remainder of the effects. Or like we're seeing with APIs such as Vec::reserve and Vec::try_reserve: the ability to panic xor return an error. This will take more time and research to figure out, but we believe it is something which can be solved.

Adding support for types

Something we're keen on doing is not just adding support for ?async and to apply to functions, traits, and trait bounds. We would like ?async to be possible to use with types as well. This would enable the ecosystem to stop having to provide both sync and async versions of crates. It would also enable the stdlib to gradually "asyncify" just like we have been with const.

The challenge with async types, especially in the stdlib, is that their behavior will often have to be different when used in async and non-async contexts. At the very lowest level async system calls work a bit differently from non-async system calls. But we think we may have a solution for that too in the form of the is_async compiler built-in method.

Say we wanted to implement ?async File with a single ?async open method. The way we expect this to look will be something like this:


#![allow(unused)]
fn main() {
/// A file which may or may not be async
struct ?async File {
    file_descriptor: std::os::RawFd,  // shared field in all contexts
    async waker: Waker,               // field only available in async contexts
    !async meta: Metadata,            // field only available in non-async contexts
}

impl ?async File {
    /// Attempts to open a file in read-only mode.
    ?async fn open(path: Path) -> io::Result<Self> {
        if is_async() {   // compiler built-in function
            // create an async `File` here; can use `.await`
        } else {
            // create a non-async `File` here
        }
    }
}
}

This would enable authors to use different fields depending on whether they're compiling for async or not, while still sharing a common core. And within function bodies it would be possible to provide different behaviors depending on the context as well. The function body notation would work as a generalization of the currently unstable const_eval_select intrinsic, and at least for the function bodies we expect a similar is_const() compiler built-in to be made available as well.

Consistent syntax

As we alluded to earlier in the post: one of the biggest challenges we see in language design is adding features in a way that makes them feel like they're in harmony with the rest of the language - and not something which stands out as noticably different. And because we're touching on something core to Rust, the way we do keywords, we have to pay extra close attention here to make sure Rust keeps feeling like a single language.

Luckily Rust has the ability to make surface-level changes to the language through the edition system. There are many things this doesn't let us do, but it does allow us to require syntax changes. A possibility we're exploring is leveraging the edition system to make some minor changes to const and async so they feel more consistent with one another, and with ?const and ?async.

For const this means there should be a syntactic distinction between const declarations and const uses. Like we mentioned earlier in the post, when you write const fn you get a function which can be evaluated both at runtime and during compilation. But when you write const FOO: () = ..; the meaning of const there guarantees compile-time evaluation. One keyword, different meanings. So for that reason we're wondering whether perhaps it would make more sense if we changed const fn to ?const fn. This would make it clear that it's a function which may be const-evaluated, but doesn't necessarily have to - and can also be called from non-const contexts.


#![allow(unused)]
fn main() {
//! Define a function which may be evaluated both at runtime and during
//! compilation.

// Current
const fn meow() -> String { .. }

// Proposed
?const fn meow() -> String { .. }
}

For async we're considering some similar surface-level changes. The Async WG is in the process of expanding the "async functions in traits" design into an design covering "async traits" entirely, largely motivated by the desire to be able to add + Send bound to anonymous futures. There are more details about this in "Lightweight, Predictable Async Send Bounds" by Eric Holk. But it would roughly become the following notation:


#![allow(unused)]
fn main() {
struct File { .. }
impl async Read for File {                                                // async trait declaration
    async fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> { .. }  // async method
}

async fn read_to_string(reader: &mut impl async Read) -> io::Result<String> { // async trait bound
    let mut string = String::new();
    reader.read_to_string(&mut string).await?;
    Ok(string)
}
}

This would make impl ?async Read and impl async Read consistent with each other. And it would open the door for trait ?async traits to be passed to impl async Read and be guaranteed to be always interpreted as trait async. Which is another nice consistency gain.

The final thing we're looking at is async-notation for types. To implement inherent ?async methods on types, our current design requires the type to also be marked as ?async. In order to bring ?async and async closer together, we're exploring whether it might also make sense to require types to be marked as async as well:


#![allow(unused)]
fn main() {
//! Proposed: define a method on a maybe-async type
struct ?async File { .. }
impl ?async File {
    ?async fn open(path: PathBuf) -> io::Result<Self> { .. }
}

//! Current: define a method on an always-async type
struct File { .. }
impl File {
    async fn open(path: PathBuf) -> io::Result<Self> { .. }
}

//! Proposed: define a method on an always-async type
struct async File { .. }
impl async File {
    async fn open(path: PathBuf) -> io::Result<Self> { .. }
}
}

We already have something similar going on for "always-const" arguments via the const-generics system. These look something like this:


#![allow(unused)]
fn main() {
fn foo<const N: usize>() {}
}

Every "always-const" argument to the function must always be marked by const, so it wouldn't be entirely without precedent for every "always-async" type to always require to be marked using async. So we're exploring some of what might be possible here.

The tentative plan

We plan to initially focus our efforts on the async and const keywords only. We're feeling ready to start converting some of our designs into RFCs, and start putting them out for review. In the coming months we expect to start writing the following proposals (in no particular order):

  • ?async fn notation without trait bounds, including an is_async mechanism.
  • trait async declarations and bounds.
  • trait ?async declarations and bounds, trait ?const declarations and bounds.
  • ?const fn notation without trait bounds.
  • struct async notation and struct ?const notation.

We'll be working closely with the Lang Team, Const WG, and Async WG on these proposals, and in some cases (such as trait async) we may even take an advising role with a WG directly driving the RFC. As usual, these will be going through the RFC-nightly-stabilization cycle. And only once we're fully confident in them will they become available on stable Rust.

We're intentionally not yet including effect/.do notation on this roadmap. We expect to only be able to start this work once we have ?async on nightly, which we don't yet have. So for now we'll continue work on designing it within the initiative, and hold off on making plans to introduce it quiet yet.

Conclusion

And that concludes the 9-month progress report of the Keyword Generics Initiative. We hope to be able to provide more exact details about things such as desugarings, semantics, and alternatives in the RFCs. We're pretty stoked with the progress we've made in these past few months! Something which I don't think we've mentioned yet, but is probably good to share: we've actually prototyped much of the work in this post already; so we're feeling fairly confident all of this may actually actually work. And that is something we're incredibly excited for!

This was originally posted on Yosh's blog, but included in this repository to be more easily referenced.

This is the transcript of the RustConf 2023 talk: "Extending Rust's Effect System", presented on September 13th 2023 in Albuquerque, New Mexico and streamed online.

Introduction

Rust has continuously evolved since version 1.0 was released in 2015. We've added major features such as the try operator (?), const generics, generic associated types (GATs), and of course: async/.await. Out of those four features, three are what can be considered to be "effects". And though we've been working on them for a long time, they are all still very much in-progress.

Hello, my name is Yosh and I work as a Developer Advocate for Rust at Microsoft. I've been working on Rust itself for the past five years, and I'm among other things a member of the Rust Async WG, and the co-lead of the Rust Effects Initiative.

The thesis of this talk is that we've unknowingly shipped an effect system as part of the language in since Rust 1.0. We've since begun adding a number of new effects, and in order to finish integrating them into the language we need support for effect generics.

In this talk I'll explain what effects are, what makes them challenging to integrate into the language, and how we can overcome those challenges.

Rust Without Generics

When I was new to Rust it took me a minute to figure out how to use generics. I was used to writing JavaScript, and we don’t have generics there. So I found myself mostly writing functions which operated on concrete types. I remember my code felt pretty clumsy, and it wasn't a great experience. Not compared to, say, the code the stdlib provides.

An example of a generic stdlib function is the io::copy function. It reads bytes from a reader, and copies them into a writer. We can give it a file, a socket, or any combination of the two, and it will happily copy bytes from one into the other. This all works as long as we give it the right types.

But what if Rust actually didn't have generics? What if the Rust I used to write at the beginning was actually all we had? How would we write this io::copy function? Well, given we're trying to copy bytes between sockets and file types, we could probably hand-code individual functions for these. For our two types here we could write four unique functions.

But unfortunately for us that would only solve the problem right in front of us. But the stdlib doesn’t just have two types which implement read and write. It has 18 types which implement read, and 27 types which implement write. So if we wanted to cover the entire API space of the stdlib, we’d need 486 functions in total. And if that was the only way we could implement io::copy, that would make for a pretty bad language.

Now luckily Rust does have generics, and all we ever need is the one copy function. This means we're free to keep adding more types into the stdlib without having to worry about implementing more functions. We just have the one copy function, and the compiler will take care of generating the right code for any types we give it.

Why effect generics?

Types are not the only things in Rust we want to be generic over. We also have "const generics" which allow functions to be generic over constant values. As well as "value generics" which allow functions to be generic over different values. This is how we can write functions which can take different values - which is a feature present in most programming languages.


#![allow(unused)]
fn main() {
fn by_value(cat: Cat) { .. }
fn by_reference(cat: &Cat) { .. }
fn by_mutable_reference(cat: &mut Cat) { .. }
}

But not everything that can lead to API duplication are things we can be generic over. For example, it's pretty common to create different methods or types depending on whether we take a value as owned, as a reference, or as a mutable reference. We also often create duplicate APIs for constant values and for runtime values. As well as create duplicate structures depending on whether the API needs to be thread-safe or not.

But out of everything which can lead to API duplication, effects are probably one of the biggest ones. When I talk about effects in Rust, what I mean is certain keywords such as async/.await and const; but also ?, and types such as Result, and Option. All of these have a deep, semantic connection to the language, and changes the meaning of our code in ways that other keywords and types don't.

Sometimes we'll write code which doesn't have the right effects, leading to effect mismatches. This is also known as the function coloring problem, as described by Robert Nystrom. Once you become aware of effect mismatches you start seeing them all over the place, not just in Rust either.

The result of these effect mismatches is that using effects in Rust essentially drops you into a second-rate experience. Whether you're using const, async, Result, or Error - almost certainly somewhere along the line you'll run into a compatibility issue.


#![allow(unused)]
fn main() {
let db: Option<Database> = ..;
let db = db.filter(|db| db.name? == "chashu");
}

Take for example the Option::filter API. It takes a type by reference and returns a bool. If we try and use the ? operator inside of it we get an error, because the function doesn't return Result or Option. Not being able to use ? inside of arbitrary closures is an example of an effect mismatch.

But simple functions like that only scratch the surface. Effect mismatches are present in almost every single trait in the stdlib too. Take for example something common like the Debug trait which is implemented on almost every type in Rust.

We could implement the Debug trait for our made-up type Cat. The parameter f here implements io::Write and represents a stream of bytes. And using the write! macro we can write bytes into that stream. But if for some reason we wanted to write bytes asynchronously into, say, an async socket. Well, we can't do that. fn fmt is not an async function, which means we can't await inside of it.

One way out of this could be to create some kind of intermediate buffer, and synchronously write data into it. We could then write data out of that buffer asynchronously. But that would involve extra copies we didn't have before.

If we wanted to make it identical to what we did before, the solution would be to create a new AsyncDebug trait which can write data asynchronously into the stream. But we now have duplicate traits, and that's exactly the problem we're trying to prevent.

It's tempting to say that maybe we should just add the AsyncDebug trait and call it a day. We can then also add async versions of Read, Write, and Iterator too. And perhaps Hash as well, since it too writes to an output stream. And what about From and Into? Perhaps Fn, FnOnce, FnMut, and Drop too since they're built-ins? And so on. The reality is that effect mismatches are structural, and duplicating the API surface for every effect mismatch leads to an exponential explosion of APIs. Which is similar to what we've seen with data type generics earlier on.

Let me try and illustrate this for a second. Say we took the existing family of Fn traits and introduced effectful versions of them. That is: versions which work with unsafe 1, async, try, const, and generators. With one effect we're up to six unique traits. With two effects we're up to twelve. With all five we're suddenly looking at 96 different traits.

1

Correction from 2024: after having discussed this with Ralf Jung we've concluded that semantically unsafe in Rust is not an effect. But syntactically it would be fair to say that unsafe is "effect-like". As such any notion of "maybe-unsafe" would be nonsensical. We don't discuss such a feature in this talk, but it is worth clearing up ahead of time in case this leaves people wondering.

The problem space in the stdlib is really broad. From analyzing the Rust 1.70 stdlib, by my estimate about 75% of the stdlib would interact with the const effect. Around 65% would interact with the async effect. And around 30% would interact with the try effect. The exact numbers are imprecise because parts of the various effects are still in-progress. How much this will result in practice, very much will depend on how we end up designing the language.

If you compare the numbers then it appears that close to 100% of the stdlib would interact with one or more effect. And about 50% would interact with two or more effects. If we consider that whenever effects interact with each other they can lead to exponential blowup, this should warn us that clever one-off solutions won't cut it. I believe that the best way to deal with this is to instead allow Rust to enable items to be generic over effects.

Stage I: Effect-Generic Trait Definitions

Now that we've taken a good look at what happens when we can't be generic over effects, it's time we start talking about what we can do about it. The answer, unsurprisingly, is to introduce effect generics into the language. To cover all uses will take a few steps, so let's start with the first, and arguably most important one: effect-generic trait definitions.

This is important because it would allow us to introduce effectful traits as part of the stdlib. Which would among other things would help standardize the various async ecosystems around the stdlib.


#![allow(unused)]
fn main() {
pub trait Into<T>: Sized {     
    fn into(self) -> T;
}
}

#![allow(unused)]
fn main() {
impl Into<Loaf> for Cat {     
    fn into(self) -> Loaf {
        self.nap()
    }
}
}

Let's use a simple example here: the Into trait. The Into trait is used to convert from one type into another. It is generic over a type T, and has one function "into" which consumes Self and returns the type T. Say we have a type cat which when it takes a nap turns into a cute little loaf. We can implement Into<Loaf> for Cat by calling self.nap in the function body.


#![allow(unused)]
fn main() {
pub trait AsyncInto<T>: Sized {     
    async fn into(self) -> T;
}
}

#![allow(unused)]
fn main() {
impl AsyncInto<Loaf> for Cat {     
    async fn into(self) -> Loaf {
        self.nap().await
    }
}
}

But what if the cat doesn't take a nap straight away? Maybe nap should actually be an async function. In order to await nap inside the trait impl, the into method would need to be async. If we were writing an async trait from scratch, we could do this by exposing a new AsyncInto trait with an async into method.

But we don't just want to add a new trait to the stdlib, instead we want to extend the existing Into trait to work with the async effect. The way we could extend the Into trait with the async effect is by making the async effect optional. Rather than requiring that the trait is always sync or async, implementors should be able to choose which version of the trait they want to implement.


#![allow(unused)]
fn main() {
#[maybe(async)]
impl Into<Loaf> for Cat {     
    #[maybe(async)]
    fn into(self) -> Loaf {
        self.nap()
    }
}
}

The way this would work is by adding a new notation on the trait: "maybe async". We don't yet know what syntax we want to use for "maybe async", so in this talk we'll be using attributes. The way the "maybe async" notation works is that we mark all methods which we want to be "maybe async" as such. And then mark our trait itself as "maybe async" too.


#![allow(unused)]
fn main() {
impl Into<Loaf> for Cat {     
    fn into(self) -> Loaf {
        self.nap()
    }
}
}

#![allow(unused)]
fn main() {
impl async Into<Loaf> for Cat {     
    async fn into(self) -> Loaf {
        self.nap().await
    }
}
}

Implementors then get to choose whether they want to implement the sync or async versions of the trait. And depending on which version they choose, the methods then ends up being either sync or async. This system would be entirely backwards-compatible, because implementing the sync version of Into would remain the same as it is today. But people who want to implement the async version would be able to, just by adding a few extra async keywords to the impl.


#![allow(unused)]
fn main() {
impl async Into<Loaf> for Cat {
    async fn into(self) -> Loaf {
        self.nap().await
    }
}
}

#![allow(unused)]
fn main() {
impl Into<Loaf, true> for Cat {
    type ReturnTy = impl Future<Output = Loaf>;
    fn into(self) -> Self::ReturnTy {
        async move {
            self.nap().await
        }
    }
}
}

Under the hood the implementations desugars to regular Rust code we can already write today. The sync implementation of the type returns a type T. But the async impl returns an impl Future of T. Under the hood it is just a single const bool and some associated types.

  • good diagnostics
  • gradual stabilization,
  • backwards-compatibility
  • clear inference rules

It would be reasonable to ask why we're bothering with a language feature, if the desugaring ends up being so simple. And the reason is: effects are everywhere, and we want to make sure effect generics feel like part of the language. That not only means that we want to tightly control the diagnostics. We also want to enable them to be gradually introduced, have clear language rules, and be backwards-compatible.

But if you keep all that in mind, it's probably okay to think of effect generics as mostly syntactic sugar for const bools + associated types.

Stage II: Effect-Generic Bounds, Impls, and Types

Being able to declare effect-generic traits is only the beginning. The stdlib not only exposes traits, it also exposes various types and functions. And effect-generic traits don't directly help with that.


#![allow(unused)]
fn main() {
pub fn copy<R, W>(
    reader: &mut R,
    writer: &mut W
) -> io::Result<()>
where
    R: Read,
    W: Write;
}

Let's take our earlier io::copy example again. As we've said copy takes a reader and writer, and then copies bytes from the reader to the writer. We've seen this.


#![allow(unused)]
fn main() {
pub fn async_copy<R, W>(
    reader: &mut R,
    writer: &mut W
) -> io::Result<()>
where
    R: AsyncRead,
    W: AsyncWrite;
}

Now what would it look like if we tried adding an async version of this to the stdlib today. Well, we'd need to start by giving it a different name so it doesn't conflict with the existing copy function. The same goes for the trait bounds as well, so instead of taking Read and Write, this function would take AsyncRead and `AsyncWrite.


#![allow(unused)]
fn main() {
pub fn async_copy<R, W>(
    reader: &mut R,
    writer: &mut W
) -> io::Result<()>
where
    R: async Read,
    W: async Write;
}

Now things get a little better once we have effect-generic trait definitions. Rather than needing to take async duplicates of the Read and Write traits, the function can instead choose the async versions of the existing Read and Write traits. That's already better, but it still means we have two versions of the copy function.


#![allow(unused)]
fn main() {
#[maybe(async)]
pub fn copy<R, W>(
    reader: &mut R,
    writer: &mut W
) -> io::Result<()>
where
    R: #[maybe(async)] Read,
    W: #[maybe(async)] Write;
}

Instead the ideal solution would be to allow copy itself to be generic over the async effect, and make that determine which versions of Read and Write we want. These are what we call "effect-generic bounds". The effect of the function and the effect of the bounds it takes all become the same. In literature this is also known as "row-polymorphism".


#![allow(unused)]
fn main() {
copy(reader, writer)?;                // infer sync
copy(reader, writer).await?;          // infer async
copy::<async>(reader, writer).await?; // force async
}

Because the function itself is now generic over the async effect, we need to figure out at the call-site which variant we intended to use. This system will make use of inference to figure it out. That's a fancy way of saying that the compiler is going to make an educated guess about which effects the programmer intended to use. If they used .await they probably wanted the async version. Otherwise they probably wanted the sync version. But as with any guess: sometimes we guess wrong, so for that reason we want to provide an escape hatch by enabling program authors to force the variant. We don't know the exact syntax for this yet, but we assume this would likely be using the turbofish notation.


#![allow(unused)]
fn main() {
struct File { .. }
impl File {
    fn open<P>(p: P) -> Result<Self>
    where
        P: AsRef<Path>;
}
}

But effect-generics aren't just needed for functions. If we want to make the stdlib work well with effects, then types will need effect-generics too. This might seem strange at first, since an "async type" might not be very intuitive. But for example files on Windows need to be initialized as either sync or async. Which means that whether they're async or not isn't just a property of the functions, it's a property of the type.

Let's use the stdlib's File type as our example here. For simplicity let's assume it has a single method: open which returns either an error or a file.


#![allow(unused)]
fn main() {
struct AsyncFile { .. }
impl AsyncFile {
    async fn open<P>(p: P) -> Result<Self>
    where
        P: AsRef<AsyncPath>;
}
}

If we wanted to provide an async version of File, we again would need to duplicate our interfaces. That means a new type AsyncFile, which has a new async method open, which takes an async version of Path as an argument. And Path needs to be async because it itself has async filesystem methods on it. As I've said before: once you start looking you notice effects popping up everywhere.


#![allow(unused)]
fn main() {
#[maybe(async)]
struct File { .. }

#[maybe(async)] 
impl File {
    #[maybe(async)]
    fn open<P>(p: P) -> Result<Self>
    where
        P: AsRef<#[maybe(async)] Path>;
}
}

Instead of creating a second AsyncFile type, with effect generics on types we'd be able to open File as async instead. Allowing us to keep just the one File definition for both sync and async variants.


#![allow(unused)]
fn main() {
#[maybe(async)]
fn copy<R, W>(reader: R, writer: W) -> io::Result<()> {
    let mut buf = vec![4028];
    loop {
        match reader.read(&mut buf).await? {
            0 => return Ok(()),
            n => writer.write_all(&buf[0..n]).await?,
        }
    }
}
}

Now I've sort of hand-waved away the internal implementations of both the copy function and the File type. The way they work is a little different for the two. In the case of the copy function, the implementation between the async and non-async variants would be identical. If the function is compiled as async, everything works as written. But if the function compiles as sync, then we just remove the .awaits and the function should compile as expected.

As a result of this "maybe-async" functions can only call sync or other "maybe-async" functions. But that should be fine for most cases.


#![allow(unused)]
fn main() {
impl File {
    #[maybe(async)]
    fn open<P>(p: P) -> Result<Self> {
        if IS_ASYNC { .. } else { .. }
    }
}
}

Concrete types like File are a little trickier. They often want to run different code depending on which effects it has. Luckily types like File already conditionally compile different code depending on the platform, so introducing new types conditions shouldn't be too big of a jump. The key thing we need is a way to detect in the function body whether code is being compiled as async or not - basically a fancy bool.

We can already do this for the const effect using the const_eval_select intrinsic. It's currently unstable and a little verbose, but it works reliably. We should be able to easily adapt it to something similar for async and the rest of the effects too.

What are effects?

Systems research on effects has been a topic in computer science for nearly 40 years. That's about as old as C++. It's become a bit of a hot topic recently in PL spheres with research languages such as Koka, Eff, and Frank showing how effects can be useful. And languages such as Scala, and to a lesser extent Swift, adopting effect features.

When people talk about effects they will broadly refer to one of two things:

  • Algebraic Effect Types: which are semantic notations on functions and contexts that grant a permission to do something.
  • Algebraic Effect Handlers: which are a kind of typed control-flow primitive which allows people to define their own versions of async/.await, try..catch, and yield.

A lot of languages which have effects provide both effect types and effect handlers. These can be used together, but they are in fact distinct features. In this talk we'll only be discussing effect types.


#![allow(unused)]
fn main() {
pub async fn meow(self) {}
pub const unsafe fn meow() {}
}

What we've been calling "effects" in this talk so far have in fact been effect types. Rust hasn't historically called them this, and I believe that's probably why effect generics weren't on our radar until recently. But it turns out that reinterpreting some of our keywords as effect types actually makes perfect sense, and provides us with a strong theoretical framework for how to reason about them.

We also have unsafe which allows you to call unsafe functions. The unstable try-block feature which doesn't require you to Ok-wrap return types. The unstable generator closure syntax which gives you access to the yield keyword. And of course the const keyword which allows you evaluate code at compile-time.


#![allow(unused)]
fn main() {
async { async_fn().await }; // async effect
unsafe { unsafe_fn() };     // unsafe effect
const { const_fn() };       // const effect
try { try_fn()? };          // try effect (unstable)
|| { yield my_type };       // generator effect (unstable)
}

In Rust we currently have five different effects: async, unsafe, const, try, and generators. All six of these are in various stages of completion. For example: async Rust has functions and blocks, but no iterators or drop. Const doesn't have access to traits yet. Unsafe functions can't be lowered to Fn traits. Try does have the ? operator, but try blocks are unstable. And generators are entirely unstable; we only have the Iterator trait.

Some of these effects are what folks on the lang team have started calling "carried". Those are effects which will desugar to an actual type in the type system. For example when you write async fn, the return type will desugar to an impl Future.

Some other effects are what we're calling: "uncarried". These effects don't desugar to any types in the type system, but serve only as a way to communicate information back to the compiler. This is for example const or unsafe. While we do check that the effects are used correctly, they don't end up being lowered to actual types.


#![allow(unused)]
fn main() {
let x = try async { .. };
}
1. -> impl Future<Output = Result<T, E>>
2. -> Result<impl Future<Output = T>, E>

When we talk about carried effects, effect composition becomes important. Take for example "async" and "try" together. If we have a function which has both? What should the resulting type be? A future of Result? Or a Result containing a Future?

Effects on functions are order-independent sets. While Rust currently does require you declare effects in a specific order, carried effects themselves can only be composed in one way. When we stabilized async/.await, we decided that if an async function returned a Result, that should always return an impl Future of Result. And because effects are sets and not dependent on ordering, we can define the way carried effects should compose as part of the language.

People can still opt-out from the built-in composition rules by manually writing function signatures. But this is rare, and for the overwhelming majority of uses the built-in composition rules will be the right choice.


#![allow(unused)]
fn main() {
const fn meow() {}  // maybe-const
const {}            // always-const
}

The const effect is a bit different from the other effects. const blocks are always evaluated during compilation. While const functions merely can be evaluated during during compilation. It's perfectly fine to call them at runtime too. This means that when we write const fn, we're already writing effect-generics. This mechanism is the reason why we've gradually been able to introduce const into the stdlib in a backwards-compatible way.

Const is also a bit strange in that among other things it disallows access to the host runtime, it can't allocate, and it can't access globals. This feels different from effects like say, async, which only allow you to do more things.

effect setcan accesscannot access
std rustnon-termination, unwinding, non-determinism, statics, runtime heap, host APIsN/A
allocnon-termination, unwinding, non-determinism, globals, runtime heaphost APIs
corenon-termination, unwinding, non-determinism, globalsruntime heap, host APIs
constnon-termination, unwindingnon-determinism, globals, runtime heap, host APIs

What's missing from this picture is that all functions in Rust carry an implicit set of effects. Including some effects we can't directly name yet. When we write const functions, our functions have a different set of effects, than if we write no_std functions, which again are different from regular "std" rust functions.

The right way of thinking about const, std, etc. is as adding a different effects to the empty set of effects. If we start from zero, then all effects are merely additive. They just add up to different numbers.

Unfortunately in Rust we can't yet name the empty set of effects. In effect theory this is called the "total effect". And some languages such as Koka do support the "total" effect. In fact, Koka's lead developer has estimated that around 70% of a typical Koka program can be total. Which begs the question: if we could express the total effect in Rust, could we see similar numbers?

Stage III: More Effects

So far we've only talked about how we could finish the work on existing effects such as const and async. But one nice thing of effect generics is that they would not only allow us to finish our ongoing effects work. It would also lower the cost of introducing new effects to the language.

Which opens up the question: if we could add more effects, which effects might make sense to add? The obvious ones would be to actually finish adding try and generator functions. But beyond that, there are some interesting effects we could explore. For brevity I'll only discuss what these features are, and not show code examples.

  • no-divergence: guarantees that a function cannot loop indefinitely, opening up the ability to perform static runtime-cost analysis.
  • no-panic: guarantees a function will never produce a panic, causing the function to unwind.
  • parametricity: guarantees that a function only operates on its arguments. That means no implicit access to statics, no global filesystem, no thread-locals.
  • capability-safety: guarantees that a function is not only parametric, but can't downcast abstract types either. Say if you get an impl Read, you can't reverse it to obtain a File.
  • destructor linearity: guarantees that Drop will always be called, making it a safety guarantee.
  • pattern types: enables functions to operate directly on variants of enums and numbers
  • must-not-move types: would be a generalization of pinning and the pin-project system, making it a first-class language feature

Though there's nothing inherently stopping us from adding any of these features into Rust today, in order to integrate them into the stdlib without breaking backwards-compatibility we need effect generics first.


#![allow(unused)]
fn main() {
effect const  = diverge + panic;
effect core   = const + statics + non_determinism;
effect alloc  = core + heap;
effect std    = alloc + host_apis;
}

This brings us to the final part of the design space: effect aliases. If we keep adding effects it's very easy to eventually reach into a situation where we have our own version of "public static void main".

In order to mitigate that it would instead be great if we could name specific sets of effects. In a way we've already done that, where const represents "may loop forever" and "may panic". If we actually had "may loop forever" and "may panic" as built-in effects, then we could redefine const as an alias to those.

Fundamentally this doesn't change anything we've talked about so far. It's just that this would syntactically be a lot more pleasant to work with. So if we ever reach a state where we have effect generics and we want notice we maybe have one too many notation in front of our functions, it may be time for us to start looking into this more seriously.

Outro

Rust already includes effect types such as async, const, try, and unsafe. Because we can't be generic over effect types yet, we usually have to choose between either duplicating code, or just not addressing the use case. And this makes for a language which feels incredibly rough once you start using effects. Effect generics provide us with a way to be generic over effects, and we've shown they can be implemented today as mostly as syntax sugar over const-generics.

We're currently in the process of formalizing the effect generic work via the A-Mir-Formality. MIR Formality is an in-progress formal model of Rust's type system. Because effect generics are relatively straight forward but have far-reaching consequences for the type system, it is an ideal candidate to test as part of the formal model.

In parallel the const WG has also begun refactoring the way const functions are checked in the compiler. In the past const-checking happened right before borrow checking at the MIR level. In the new system const-checking will happen much sooner, at the HIR level. This will not only make the code more maintainable, it will also be generalizable to more effects if needed.

Once both the formal modeling and compiler refactorings conclude, we'll begin drafting an RFC for effect-generic trait definitions. We expect this to happen sometime in 2024.

And that's the end of this talk. Thank you so much for being with me all the way to the end. None of the work in this talk would have been possible without the following people:

  • Oliver Scherer (AWS)
  • Eric Holk (Microsoft)
  • Niko Matsakis (AWS)
  • Daan Leijen (Microsoft)

Thank you!

πŸ“œ keyword generics Charter

One of Rust's defining features is the ability to write functions which are generic over their input types. That allows us to write a function once, leaving it up to the compiler to generate the right implementations for us.

When we introduce a new keyword for something which used to be a trait, we not only gain new functionality - we also lose the ability to be generic over that keyword. This proposal seeks to change that by introducing keyword generics: the ability to be generic over specific keywords.

This proposal is scoped to the const and async keywords only, but is designed to be leveraged by other keywords as well in the future. Keywords are valuable, generics are valuable, users of Rust shouldn't have to choose between the two.

Proposal

We're in the process of adding new features to Rust. The Const WG is creating an extension to Rust which enables arbitrary computation at compile time. While the Async WG is in the process of adding capabilities for asynchronous computation. We've noticed that both these efforts have a lot in common, and may in fact require similar solutions. This document describes a framework for thinking about these language features, describes their individual needs, and makes the case that we should be considering a generalized language design for "keywords" (aka "definitely not effects"), so that we can ensure that the Rust language and standard library remain consistent in the face of extensions.

A broad perspective on language extensions

const fn and async fn are similar language extensions, but the way they extend the language is different:

  • const fn creates a subset of "base Rust", enabling functions to be executed during compilation. const functions can be executed in "base" contexts, while the other way around isn't possible.
  • async fn creates a superset of "base Rust", enabling functions to be executed asynchronously. async types cannot be executed in "base" contexts 1, but "base" in async contexts is possible.
1

In order to bridge async and non-async Rust, functionality such as thread::block_on or async fn must be used, which runs a future to completion from a synchronous context. const Rust does not require such a bridge, since the difference in contexts is "compile time" and "run-time".

                      +---------------------------+                               
                      | +-----------------------+ |     Compute values:
                      | | +-------------------+ | |     - types
                      | | |                   | | |     - numbers
                      | | |    const Rust     |-------{ - functions               
                      | | |                   | | |     - control flow            
 Access to the host:  | | +-------------------+ | |     - traits (planned)                 
 - networking         | |                       | |     - containers (planned)
 - filesystem  }--------|      "base" Rust      | |                               
 - threads            | |                       | |                               
 - system time        | +-----------------------+ |     
                      |                           |     Control over execution:      
                      |         async Rust        |---{ - ad-hoc concurrency      
                      |                           |     - ad-hoc cancellation     
                      +---------------------------+     - ad-hoc pausing/resumption

In terms of standard library these relationships also mirror each other. "Base" Rust will want to do everything during runtime what const rust can do, but in addition to that also things like network and filesystem IO. Async Rust will in turn want to do everything "base" Rust can do, but in addition to that will also want to introduce methods for ad-hoc concurrency, cancellation, and execution control. It will also want to do things which are blocking in "base" Rust as non-blocking in async Rust.

And it doesn't stop with const and async Rust; it's not hard to imagine that other annotations for "can this panic", "can this return an error", "can this yield values" may want to exist as well. All of which would present extensions to the "base" Rust language, which would need to be introduced in a way which keeps it feeling like a single language - instead of several disjoint languages in a trenchcoat.

Membership

RoleGithub
OwnerYosh Wuyts
OwnerOli Scherer
LiaisonNiko Matsakis?

πŸ”¬ Evaluation

The evaluation surveys the various design approaches that are under consideration. It is not required for all initiatives, only those that begin with a problem statement but without a clear picture of the best solution. Often the evaluation will refer to topics in the design-discussions for more detailed consideration.

Syntax

  • Name: (fill me in: name-of-design)
  • Proposed by: [@name](link to github profile)
  • Original proposal (optional): (url)

Design

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async


#![allow(unused)]
fn main() {
// fill me in
}

maybe async


#![allow(unused)]
fn main() {
// fill me in
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
// fill me in
}

Notes

  • Name: attribute based effects
  • Proposed by: @oli-obk
  • Original proposal (optional): (url)

Design

Use function and trait attributes to make a function/trait have effect-like behaviour instead of adding new syntax. There's still some new syntax in trait bounds, but these are removed by the attribute at attribute expansion time.

This is experimentally being built with a proc macro in https://github.com/yoshuawuyts/maybe-async-channel.

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async


#![allow(unused)]
fn main() {
#[async]
pub trait Iterator {
    type Item;
    #[async]
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
#[async]
fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: async Iterator<Item = T> + Sized,
    P: async FnMut(&T) -> bool;
}

maybe async


#![allow(unused)]
fn main() {
#[maybe_async]
pub trait Iterator {
    type Item;
    #[maybe_async]
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
#[maybe_async]
fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: async Iterator<Item = T> + Sized,
    P: async FnMut(&T) -> bool;
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
#[effect]
pub trait Iterator {
    type Item;
    #[effect]
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
#[effect]
fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: effect Iterator<Item = T> + Sized,
    P: effect FnMut(&T) -> bool;
}

Notes

Design

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async

In all The methods on the trait are assumed async because the trait is async.

Variation A:


#![allow(unused)]
fn main() {
pub async trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    !async fn size_hint(&self) -> (usize, Option<usize>);
}

pub async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: async FnMut(&T) -> bool;
}

Variation B. Using an "effect-generics" notation:


#![allow(unused)]
fn main() {
pub trait Iterator<effect async> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint<effect !async>(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect async>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut<effect async>(&T) -> bool;
}

Variation C. Using an effect-notation in where-bounds:


#![allow(unused)]
fn main() {
pub trait Iterator
where
    effect async
{
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>)
    where
        effect !async;
}

pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut<effect async>(&T) -> bool;
}

maybe async

For all variations the use of <effect async = A> on fn next is elided.

Variation A. Using an effect A: async + !async fn in the trait definition:


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: async> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    !async fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect A: async>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T, effect async = A> + Sized,
    P: FnMut<effect async = A>(&T) -> bool;
}

Variation B. Using effect A: async + effect! async in the trait definition:


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: async> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint<effect !async>(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect A: async>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T, effect async = A> + Sized,
    P: FnMut<effect async = A>(&T) -> bool;
}

Variation C. Using effect A: async + where effect !async notation. If we'd instead written where A = !async, the size_hint method would only exist if the context was not async. It instead now exists as not async in all contexts:


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: async> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>)
    where
        effect !async;
}

pub fn find<I, T, P, effect A: async>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T, effect async = A> + Sized,
    P: FnMut<effect async = A>(&T) -> bool;
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: for<effect>> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    !async fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect A: for<effect>>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T, for<effect> = A> + Sized,
    P: FnMut<for<effect> = A>(&T) -> bool;
}

See also

Notes

!async fn foo could be sync fn foo or omitted entirely in favor of only having fn foo<effect !async>. It is also a question if all effects should allow for effect fn foo syntax.

for<effect> should maybe be made more special-looking since it behaves quite differently from other generic effect variables.

The exact syntax of effect A: E and effect E = A for declaring a generic and specifying a bound for an effect could maybe be made different.

It might be easier to implement specialization for specifically effect-generics, as they are rather simple, effectively just being bools, and there not being any lifetime parameters on them.

Some nice things about the syntax

Specific behavior

To make a function have specific behavior in the case where an effect is or is not true, we could do this:

fn foo<effect A: async>() {
    if A {
        // do stuff when foo is async
    } else {
        // do stuff when foo is not async
    }
}

Impl blocks

impl blocks could look very similar to any other generics.


#![allow(unused)]
fn main() {
impl<effect A: async> SomeTrait<effect async = A> MyGenericType { ... }
impl SomeTrait<effect async> MyAsyncType { ... }
impl SomeTrait<effect !async> MySyncType { ... }
}

Description

We can add effects to generics like <effect A: E>, and create bounds for the effects of types by doing effect E = A in the <..> list or the where-clause.

The basic syntax is that effect async = true means the type is async, whereas effect async = false means it is not.

For convenience we'd let effect async be the same as effect async = true and effect !async be the same as effect async = false.

async fn foo would be syntactic sugar for fn foo<effect async = true>. and similar for other effects.

So as an example, here are some equivalent ways of writing an async function:


#![allow(unused)]
fn main() {
fn foo<T, O, const N: usize, effect async = true>(...) {...}
fn foo<T, O, const N: usize, effect async>(...) {...}
async fn foo<T, O, const N: usize>(...) {...}
fn foo<T, O, const N: usize>(...) where effect async {...}
}

Every effect has a default value, and if there is no bound on the type for that specific effect it is assumed to have its default value. So the function above, having no bound on const, would be assumed not-const.

This could be explicitly stated like


#![allow(unused)]
fn main() {
async fn foo<T, O, const N: usize>(...) where effect !const {...}
}

However this would be unneccesary.

If a type has only one generic for an effect, and no other bounds for that effect. It is assumed to have the same bound as that one generic. Meaning the following are equivalent ways of making a function generic over async.


#![allow(unused)]
fn main() {
fn foo<T, O, const N: usize, effect A: async>foo(...) where effect async = A {...}
fn foo<T, O, const N: usize, effect A: async>foo(...) {...}
}

However if there are multiple generics, we'd need to explicitly state what the bound should be for the type itself.


#![allow(unused)]
fn main() {
fn foo<T, O, const N: usize, effect A: async, effect B: async>foo(...) where effect async = A | B {...}
}

This would mean that foo is async if either A is true or B is true. We could also use A + B if wanted it to be async whenever both are true.

Declaring an type to have/not have an effect different from the default value might change the type. For instance fn foo<effect async>() -> T would become foo() -> Future<Output = T>.

Every generic effect variable (except for<effect>) is also like a constant boolean value, which is true whenever the type is in a context where it has that effect, and false otherwise.

In traits, the items are assumed to have the same effect bounds as the trait itself. But this can be overridden using specific bounds for that item.

For instance

trait Read<effect A: async> {
    // This function is now generic over async
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    // or equivalently
    fn read(&mut self, buf: &mut [u8]) -> Result<usize> where effect async = A;

    // This function is now always async
    async fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
    // or equivalently
    fn read(&mut self, buf: &mut [u8]) -> Result<usize> where effect async;

    // This function now only exists when the trait is async
    fn read(&mut self, buf: &mut [u8]) -> Result<usize> where A;
}

This also shows that unlike normal const _: bool we can actually use whether the generic effects are true/false in the where-clause.

for<effect>

for<effect> is a universal effect bound that allows you to place bounds on all the effects of a type. Adding a effect A: for<effect> makes A a generic variable that ranges over every effect. This means its value is no longer a simple true/false and so can't be used bare in where-clauses.

If another bound is added that is more specific, that bound will limit the possible values of A as well. Meaning that if you have <effect A: for<effect>, effect async>, we would have the type be generic over every effect except async. And the type would always be async.

For instance, to make a function generic over all effects except const we'd write


#![allow(unused)]
fn main() {
fn foo<effect A: for<effect>>(...) where effect async {...}
}

To place bounds on every effect we write for<effect> = A where A is some bound. This should probably be limited somewhat to avoid people writing code that can very easily break. Consider for instance for<effect> = true, which would declare something as having every effect. This could lead to breakage if a new effect is added and the function isn't compatible with this new effect. The main uses of placing bounds on for<effect> would to use it with other universal bounds.

Using A + B and A | B bounds for universal bounds may also be problematic, as it may not always be possible to create any meaningful code that is generic in all those cases. So we may have to either disallow having multiple generic universal bounds, or have the compiler automatically infer the relationship between effects.

For instance


#![allow(unused)]
fn main() {
fn foo<O, F1, F2, effect A: for<effect>, effect B: for<effect>>(closure1: F1, closure2: F2) -> O
where
    F1: FnMut<for<effect> = A>() -> O,
    F2: FnMut<for<effect> = B>() -> O
{ ... }
}

Here it is unclear when foo should be async and const. For instance, usually a function is async if there is any async code in the function. Whereas it is const if all the code is const.

I'm not entirely sure if this is best left up to the compiler to infer, it should be disallowed, or if the user must specify the bounds on every specific effect they may use.

However if the compiler infers it all, we could still specify specific relationships, like:


#![allow(unused)]
fn main() {
fn foo<O, F1, F2, effect A: for<effect>, effect B: for<effect>>(closure1: F1, closure2: F2) -> O
where
    effect async = A + B,
    F1: FnMut<for<effect> = A>() -> O,
    F2: FnMut<for<effect> = B>() -> O
{ ... }
}

To make this function async only if both A and B are async (or rather async = true in both sets A and B).

semi-formal description

Syntax There's a new kind of generic called effect-generics. For any given type, that effect may be `true` meaning the type has that effect, or it can be `false` meaning the type does not have that effect.

We can make a type generic over an effect by adding effect A: E, where A is a generic variable and E is an effect.

An effect bound is one of: true, false, default, A, B1 + B2, B1 | B2, !B1. Where A is a generic variable, B1 and B2 are effect bounds.

An effect is either: the name of an effect, a generic variable, or for<effect>

To specify that a type must fit some effect bound we write effect E = A, where E is an effect and A is an effect bound, either in the <..> list or in the where-clause.

Semantics
  • effect E = true means "has the effect E"
  • effect E = false means "does not have the effect E"
  • effect E = default means "has the effect E if the default for the effect is true"
  • effect E = A where A is a generic variable, means "has the effect E if A is true"
  • effect E = B1 + B2 means "has the effect E if the bounds B1 and B2 are true"
  • effect E = B1 | B2 means "has the effect E if the bounds B1 or B2 are true"
  • effect E = !B means "has the effect E if the bound B is false"
  • effect for<effect> = B means "the effect bound B applies to every effect"
  • effect A: E means "A is a generic variable corresponding to the effect E"

for<effect> bounds and traits

In the generic over all keywords case we'd have that size_hint is generic over all effects except async. So it might be better to make such universal bounds not automatically apply to all items in a trait.

In that case we'd have


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: for<effect>> {
    type Item;
    fn next(&mut self) -> Option<Self::Item> where for<effect> = A;
    fn size_hint(&self) -> (usize, Option<usize>);
}
}

Alternatively we could have an opt-out syntax, which would look something like


#![allow(unused)]
fn main() {
pub trait Iterator<effect A: for<effect>> {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>) where for<effect> = default;
}
}
  • Name: effect-as-a-clause
  • Proposed by: @mominul @satvikpendem
  • Original proposal (optional): (https://github.com/rust-lang/keyword-generics-initiative/issues/14)

Design

We want to propose the usage of the effect clause to achieve operation genericity, for example:


#![allow(unused)]
fn main() {
trait Read {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>
    effect
        async;

    fn read_to_string(&mut self, buf: &mut String) -> Result<usize> 
    effect
        async
    { .. }
}

/// Function to read from the file into a string which may exhibit async or const effect
fn read_to_string(path: &str) -> io::Result<String>
effect
       async, const 
{
    let mut string = String::new();

    // We can be conditional over the context the function has been called from, 
    // only when the function declaration has the `effect` clause
    if async || !async {
        let mut file = File::open("foo.txt")?; // File implements Read
        // Because `read_to_string` is also an `effect` function that may or may not exhibit 
        // async-ness par the declaration, we can use it on both contexts (async/sync) 
        // we are placing the condition on.
        file.read_to_string(&mut string)?;  // .await will be inferred.   
    } else { // must be const
        // As the `read_to_string` doesn't exhibit const-ness, we'll need to handle it ourselves.
        string = include_str!(path).to_string();
    }

    Ok(string)
}

/// A normal function
fn read() {
    let data = read_to_string("hello.txt").unwrap();
}

/// A async function
async fn read() {
    let data = read_to_string("hello.txt").await.unwrap();
}

/// A const function
const fn read() {
    let data = read_to_string("hello.txt").unwrap();
}
}

So in a nutshell, a function declaration with an effect clause is a special type of function that may or may not exhibit async or const behavior(effect) and it depends on the context of the function being called from and we can execute a different piece of code according to the context from the function was called from too (like the const_eval_select, resolves #6):


#![allow(unused)]
fn main() {
fn function() -> Result<()>
effect
    async, const
{
    // ...
    if async {
        // code for handling stuff asynchronously
    } else if const {
        // code for handling stuff `const`-way
    else {
        // code for handling stuff synchronously
    }
    // ...
}
}

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

pub async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: async FnMut(&T) -> bool;
}

maybe async


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>
    effect async;
    fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool effect async;
effect
    async
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>
    effect async, const;
    fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool effect async, const;
effect
    async, const
}

Notes

We can introduce maybe keyword instead of effect if it seems more appropriate terminology for the semantics described in this proposal.

Design

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait async? Iterator {
    type Item;
    async? fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub async? fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: async? Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async


#![allow(unused)]
fn main() {
/// An adaptation of `Iterator::find` to a free-function
pub async fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: async Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

maybe async


#![allow(unused)]
fn main() {
pub async? fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: async? Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait effect Iterator {
    type Item;
    effect fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}
}

Notes

This is just a postfix version of the originally proposed syntax. This should appear more familiar, as the question mark is normally used at the end of a sentence, not at the beginning, and it looks similar to typescripts nullable types. it also makes generic references more legible &mut? T vs &?mut T.

  • Name: where effect bounds
  • Proposed by: @CaioOliveira793
  • Original proposal: None

Design

This syntax focus on being simple and recognizable rust code, with the possibility to incrementally extend the capabilities that keyewords generic may provide.

base (reference)


#![allow(unused)]
fn main() {
/// A trimmed-down version of the `std::Iterator` trait.
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    fn size_hint(&self) -> (usize, Option<usize>);
}

/// An adaptation of `Iterator::find` to a free-function
pub fn find<I, T, P>(iter: &mut I, predicate: P) -> Option<T>
where
    I: Iterator<Item = T> + Sized,
    P: FnMut(&T) -> bool;
}

always async


#![allow(unused)]
fn main() {
pub trait Iterator<effect>
where
    effect: async
{
    type Item;

    // opt-in for a always async effect
    fn next(&mut self) -> Option<Self::Item>
    where
        effect: async;

    // the size_hint is left unchanged, since the effect is opt-in
    fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect>(iter: &mut I, predicate: P) -> Option<T>
where
    effect: async,
    I: Iterator<Item = T> + Sized,
    <I as Iterator>::effect: async,
    P: FnMut(&T) -> bool,
    <P as FnMut>::effect: async;
}

maybe async


#![allow(unused)]
fn main() {
pub trait Iterator<effect>
where
    effect: ?async
{
    type Item;

    fn next(&mut self) -> Option<Self::Item>
    where
        effect: ?async;

    fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect>(iter: &mut I, predicate: P) -> Option<T>
where
    effect: ?async,
    I: Iterator<Item = T> + Sized,
    <I as Iterator>::effect: ?async,
    P: FnMut(&T) -> bool,
    <P as FnMut>::effect: ?async;
}

generic over all modifier keywords


#![allow(unused)]
fn main() {
pub trait Iterator<effect>
where
    // LIMITATION: in order to be generic over all keywords the effect clause must specify all keywords available
    effect: ?async + ?const
{
    type Item;

    fn next(&mut self) -> Option<Self::Item>
    where
        effect: ?async + ?const;

    fn size_hint(&self) -> (usize, Option<usize>);
}

pub fn find<I, T, P, effect>(iter: &mut I, predicate: P) -> Option<T>
where
    effect: ?async + ?const,
    I: Iterator<Item = T> + Sized,
    <I as Iterator>::effect: ?async + ?const,
    P: FnMut(&T) -> bool,
    <P as FnMut>::effect: ?async + ?const;
}

Notes

Trait effect bounds

The syntax for specifying the effect of a trait implemented by some generic argument <I as Iterator>::effect: const could be different


#![allow(unused)]
fn main() {
I: Iterator<effect = ?async + ?const>

// or

// Associated type bounds [RFC 2289](https://github.com/rust-lang/rfcs/blob/master/text/2289-associated-type-bounds.md)
I: Iterator<effect: ?async + ?const>
}

The current way mimics how associated types are bound


#![allow(unused)]
fn main() {
fn print_iter<I, effect>(iter: I)
where
    effect: ?async,
    I: Iterator,
    <I as Iterator>::Item: Display,
    <I as Iterator>::effect: ?async;
}

Explicit generic over all modifier keywords

The syntax does not give shortans for specifying all modifiers at once. Instead, the function, trait or type should explicit bound over all keywords it could be generic.

Although being inconvenient to list it manually, this has some advantages over the generic over all keywords available syntax.

Explicit

Readers does not have to remind which keywords are available that may need to be implemented in some specific way.

Backwards compatible to introduce new keywords in the language

Allowing the generic over all means that in case a new keyword lands, all complete generic functions and traits may be affected by the keyword, requiring at least some considerations on the side effects.

Limitations

These are some limitations (hopefully, not yet supported features) noticed in the syntax.

Effect sets

Function generic over sets of effects, limiting it to be called by only one group of effects.


#![allow(unused)]
fn main() {
fn compute<effect<KernelSpace | UserSpace | PreComputed>>() -> Response
where
    effect<KernelSpace>: !alloc + !panic + !async,
    effect<UserSpace>: alloc + ?async,
    effect<PreComputed>: const
{
    if effect<KernelSpace> {
        // ensures that in "KernelSpace" will not alloc, panic or run futures
    }
    if effect<UserSpace> {
        // allow allocations and futures
    }
    if effect<PreComputed> {
        // only compile-time evaluation
    }
}

fn caller1()
where
    effect: ?alloc + !panic + !async
{
    compute<effect<KernelSpace>>(); // allowed
}

fn caller2()
where
    effect: alloc + async
{
    compute<effect<KernelSpace>>(); // allowed
    compute<effect<UserSpace>>(); // allowed
}

fn caller3()
where
    effect: !alloc
{
    compute<effect<UserSpace>>(); // compiler error
}

fn caller4()
where
    effect: const
{
    compute<effect<PreComputed>>(); // allowed
}
}

Pattern Types and Backwards Compatibility

Introduction

Pattern types are an in-progress proposal for Rust to add a limited form of refinement types / liquid types to Rust via pattern the pattern notation. Take for example the existing AtomicBool::load operation. Its signature looks like this:


#![allow(unused)]
fn main() {
/// A boolean type which can be safely shared between threads.
struct AtomicBool { .. }

impl AtomicBool {
    /// Loads a value from the bool.
    pub fn load(&self, order: Ordering) -> bool { .. }
}
}

Atomics are part Rust's memory model, and are how we're able to share data between threads. Depending on what we want to do with an atomic, we'll want to give it a different Ordering argument. Ordering is just an enum, which has the following variants:


#![allow(unused)]
fn main() {
#[non_exhaustive]
pub enum Ordering {
    Relaxed,
    Release,
    Acquire,
    AcqRel,
    SeqCst,
}
}

For this example it doesn't exactly matter what each of these variants are for. But what's important is that not all variants are valid arguments for AtomicBool::load. Its documentation says that SeqCst, Acquire, and Relaxed are valid. But if the Release or AcqRel variants are used, it will panic at runtime.

Pattern types would in theory enable us to "shift-left" on this, by encoding the allowed variants directly into the function's parameters. This would encode this invariant directly via the type system, meaning we've "shifted left" from a runtime error (e.g. we need to run tests to find the bug), to a compiler error (e.g. we need to run cargo check to find the bug). Using the pattern types draft RFC, this would look something like this:


#![allow(unused)]
fn main() {
struct AtomicBool { .. }
impl AtomicBool {
    pub fn load(&self,
        order: Ordering is Ordering::SeqCst | Ordering::Acquire | Ordering::Relaxed
    ) -> bool { .. }
}
}

Backwards-compatibility issues

Moving checks from runtime to compile-time is generally considered a good thing, as it shortens the time it takes to discover bugs. But when we use pattern types as inputs to functions, we're constraining the input space from all variants to just the legal variants. Take for example the following code, which is legal to write today.


#![allow(unused)]
fn main() {
pub fn load_wrapper(order: Ordering, bool: &AtomicBool) -> bool {
    bool.load(order)
}
}

This code does not know about pattern types, and Rust's backwards-compatibility guarantees require that it keeps compiling in future releases of the compiler. That means that changing AtomicBool::load to require taking pattern types as its input would be a backwards-incompatible change. So we cannot just do that.

One alternative would be to create a duplicate version of AtomicBool which does know how to take pattern types. But duplicating code just to improve it feels pretty bad - instead it would be nice if we could update existing functions without it leading to breaking changes.

Resolving the backwards-compatibility issues

On Zulip people have brought up the idea of using editions to resolve these issues. That might be possible, but it would mean a clean break between code written on an older edition, and code written on a newer edition. And while we can leverage editions to change defaults in the language, this kind of break feels like it would push against the intended goal of maintaining compatibility between editions.

The idea underlying it seems right though: we do want some way to express modality in our type system. We've already done this before using the const effect. Functions tagged as const can be evaluated either during compilation or at runtime. And it's backwards-compatible to take an existing runtime-only fn and change it to a const fn.


#![allow(unused)]
fn main() {
fn meow() -> &'static str { "meow" }         // 1. The base `fn meow`
const fn meow() -> &'static str { "meow" }   // 2. Changing `meow` to a `const fn` is backwards-compatible
}

We could do something very similar with pattern types as well. The base mechanism for this is to define a function which can be compiled in one of two modes:

  1. Invariants are evaluated at compile-time: The pattern types are evaluated by the compiler according to the pattern type RFC. A compiler error is raised if the pattern's invariants are violated.
  2. Invariants are evaluated at runtime: The pattern types are converted to a sequence of assertions, and evaluated at runtime.

The translation here from pattern types to runtime assertions should be fairly mechanical. We could imagine some notation which signals that while a function may declare pattern types, the caller has an option to either evaluate them at runtime or during compilation. Taking our earlier AtomicBool::load example, we could imagine something like this:


#![allow(unused)]
fn main() {
struct AtomicBool { .. }
impl AtomicBool {
    #[maybe(pattern_types)]
    pub fn load(&self,
        order: Ordering is Ordering::SeqCst | Ordering::Acquire | Ordering::Relaxed
    ) -> bool { .. }
}
}

With this notation, all existing uses of AtomicBool::load would continue working. But optionally it could be called using pattern types, which would be evaluated at compile-time. Depending on which variant of the function is selected, the lowering of the function would change. Desugared, this would roughly look like this:


#![allow(unused)]
fn main() {
/// Semantic lowering of `AtomicBool::load`
/// using compile-time checks
pub fn load(&self,
    order: Ordering is Ordering::SeqCst | Ordering::Acquire | Ordering::Relaxed
) -> bool { .. }

/// Semantic lowering of `AtomicBool::load`
/// using runtime assertions
pub fn load(&self, order: Ordering) -> bool {
    match order {
        order @ Ordering::SeqCst | Ordering::Acquire | Ordering::Relaxed => ..,
        order => panic!("Expected `Ordering::{{Acquire | Relaxed | SeqCst}}`, received {order}"),
    }
}
}

TODO: Effect logic and notation

  • in its base there are four states possible: always | never | maybe | unknown
  • maybe(pattern_types) is a backwards-compatibility guarantee. Having logical never
    • always markers will put us in a position where we can eventually pull the lever across an edition to default all functions to default to always using pattern types - without breaking any existing code or breaking code compat.
  • unlike maybe(async), by lowering to runtime checks functions which use maybe patterns should always be able to call functions which take always patterns - runtime assertions using match will be enough to shrink the input state to be valid from that point onward
  • the relation to subtyping and return types will affect which states of this system we may want to encode
  • unclear what the benefits are for a strictly "always subtyping" notation
  • in practice we'll want to independently gate the stabilization of pattern types for existing stdlib APIs - which means we need a labeling system in the compiler

Example: how to combine effect states for pattern types

Nadrieril asked the following question:

Consider the case where crate A uses compile-time checks for pattern types and crate B uses crate A but has no knowledge of pattern types. If we encode this choice as an effect, we must be careful not to bubble it up (as effects do) to a function that has no knowledge of pattern types.

Let's write this example out. We're going to write three functions: one which always uses pattern types, one which may use pattern types, and a function which doesn't use pattern types. They all call each other, and that should Just Work. Let's start with the always-pattern function.


#![allow(unused)]
fn main() {
/// This function always evaluates pattern
/// types at compile-time.
fn always(num: u8 is 0..10) {
    println!("received number {num}");
}
}

There's nothing too special about this function: it always takes a pattern type, meaning we can't just give it any u8 - it needs to fit the pattern. Next, let's write out a maybe-pattern function which either takes a pattern or a base type - and depending on which variant is passed will either validate the input during compilation or at runtime. This will then call into our always function.


#![allow(unused)]
fn main() {
/// This function can evaluate pattern types
/// either at compile-time or at runtime
#[maybe(pattern_types)]
fn maybe(num: u8 is 0..10) {
    always(num);
}
}

This function either evaluates patterns during compilation or at runtime. As we've seen before: if a pattern is evaluated at runtime, it will effectively work as a match + panic!. As a result this function guarantees it will always validate its inputs, meaning once we gain access to num in the function body it will always conform to the pattern. And so we have no problem calling the always function.

Next up is our function never, which never evaluates patterns. It takes a bare u8 with no restrictions on it whatsoever. It should be able to call the maybe function without an issue.


#![allow(unused)]
fn main() {
/// This function does not reason about pattern types
fn never(num: u8) {
    maybe(num);
}
}

But if we try calling the always function from never, we run into issues:


#![allow(unused)]
fn main() {
/// This function does not reason about pattern types
fn never(num: u8) {
    always(num);  // ❌ compiler error
}
}

This should result in a compiler error along these lines:

error[E0308]: mismatched types
 --> src/lib.rs:4:12
  |
4 |     always(num);
  |     ------ ^^^^^ expected `u8 is 0..10`, found `u8`
  |     |
  |     arguments to this function are incorrect

The easiest way to resolve this would be to rewrite the never function to take the same signature as the maybe function. This would insert the correct runtime checks, contraining the value to the right pattern, which as we've seen would make it possible to call the always function without any issues.

How widespread is this?

Maintaining strict backwards-compatibility is primarily a concern for the Rust stdlib. While it might be difficult to create major versions for certain other codebases, the Rust stdlib is in the unique position that it is both used by everyone, and we can never break existing APIs. So when we're looking at using pattern types in input positions, it's okay to assume the Rust stdlib will be the main user of it. To date we know of at least the following APIs which would want to leverage pattern types as inputs:

  • number primitives: Number types in Rust expose a wide range of operations. Take for example a look at the u8 type. It exposes around 20 operations per type which will panic if certain number ranges are passed.
  • atomics: this is the example we've been using in this post. Atomic operations take an Ordering enum, where each operation can only take certain variants of that enum. Being able to check that during compilation would be a boon.
  • iterator methods: For example Iterator::step_by currently takes a usize, but would want to take a usize is 1... The same is true for the unstable Iterator::array_chunks and Iterator::map_windows.

A note on subtyping

So far we have assumed that pattern types will subtype. That means that if we have a u32 is 0..10, we can pass that anywhere a u32 is accepted. And if we have a function that returns a u32, it would not be a breaking change to restrict that to become a pattern. Enabling patterns to subtype would be complicated, and may not be reasonably possible. If that is the case, then changing any argument or return type in any existing API would be backwards-incompatible.

Even if types don't strictly sub-type, it is likely still going to be possible to cast from pattern types back to their base types since it's infallible and should be supported by the language. That means the following would likely be supported:


#![allow(unused)]
fn main() {
let x: u8 as 0..10 = 2;
let x: u8 = x as u8;
}

Auto Concurrency

Async Rust brings three unique capabilities to Rust: the ability to apply ad-hoc concurrency, the ability to arbitrarily pause, cancel and resume operations, and finally the ability to combine these capabilities into new ones - such as ad-hoc timeouts. Async Rust also does one other thing: it decouples "concurrency" from "parallelism" - while in non-async Rust both are coupled into the "thread" primitive.

One challenge however is to make use of these capabilities. People notoriously struggle to use cancellation correctly, and are often caught off guard that computations after being suspended at an .await point may not necessarily be resumed ("cancelled"). Similarly: users will often struggle to apply fine-grained concurrency in their applications - because it fundamentally means exploding sequential control-flow sequences into Directed Acyclic control-flow Graphs (control-flow DAGs).

By Example: Swift

Swift has introduced the async let keyword to enable linear-looking control-flow which statically expands to a concurrent DAG backed by tasks. To see how this works we can reference SE-0304's example which provides a makeDinner routine:

func makeDinner() async throws -> Meal {
  async let veggies = chopVegetables()                    // 1. concurrent with: 2, 3
  async let meat = marinateMeat()                         // 2. concurrent with: 1, 3
  async let oven = preheatOven(temperature: 350)          // 3. concurrent with: 1, 2, 4

  let dish = Dish(ingredients: await [try veggies, meat]) // 4. depends on: 1, 2, concurrent with: 3
  return await oven.cook(dish, duration: .hours(3))       // 5. depends on: 3, 4, not concurrent
}

The following constraints and operations occur here:

  • constraint: dish depends on veggies and meat.
  • concurrency: veggies, meat, and oven are computed concurrently
  • constraint: Meal depends on oven and dish
  • concurrency: oven and dish are computed concurrently

In Swift the async let syntax automatically spawns tasks and ensures that they resolve when they need to. In Swift await {} and try {} apply not just to the top-level expressions but also to all sub-expressions, so for example awaiting the oven is handled by await oven.cook (..). We can translate this to Rust using the futures-concurrency library without having to use parallel tasks - just concurrent futures. That would look like this:


#![allow(unused)]
fn main() {
use futures_concurrency::prelude::*;

async fn make_dinner() -> SomeResult<Meal> {
    let dish = {
        let veggies = chop_vegetables();
        let meat = marinate_meat();
        let (veggies, meat) = (veggies, meat).try_join().await?;
        Dish::new(&[veggies, meat]).await
    };
    let (dish, oven) = (dish, preheat_oven(350)).try_join().await?;
    oven.cook(dish, Duration::from_mins(3 * 60)).await
}
}

Compared to Swift the control-flow here is much harder to tease apart. We've accurately described our concurrency DAG; but reversing it to understand intent has suddenly become a lot harder. Programmers generally have a better time understanding code when it can be read sequentially; and so it's no surprise that the Swift version is better at stating intent.

Auto-concurrency for Rust's Async Effect Contexts

Rust's async system differs a little from Swift's, but only in the details. The main differences as it comes to what we'd want to do here are three-fold:

  1. Swift's async primitive are tasks: which are managed, parallel async primitives. In Rust it's Future, which is unmanaged and not parallel by default - it's only concurrent.
  2. In Rust all .await points have to be explicit and recursive awaiting of expressions is not supported. This is because as mentioned earlier: functions may permanently yield control flow at .await points, and so they have to be called out in the source code.

For these reasons we can't quite do what Swift does - but I believe we could probably do something similar. From a language perspective, it seems like it should be possible to do a similar system to async let. Any number of async let statements can be joined together by the compiler into a single control-flow graph, as long as their outputs don't depend on each other. And if we're calling .await? on async let statements we can even ensure to insert calls to try_join so concurrently executing functions can early abort on error.


#![allow(unused)]
fn main() {
async fn make_dinner() -> SomeResult<Meal> {
    async let veggies = chop_vegetables();  // 1. concurrent with: 2, 3
    async let meat = marinate_meat();       // 2. concurrent with: 1, 3
    async let oven = preheat_oven(350);     // 3. concurrent with: 1, 2, 4

    async let dish = Dish(&[veggies.await?, meat.await?]);   // 4. depends on: 1, 2, concurrent with: 3
    oven.cook(dish.await, Duration::from_mins(3 * 60)).await // 5. depends on: 3, 4, not concurrent
}
}

Here, just like in the Swift example, we'd achieve concurrency between all independent steps. And where steps are dependent on one another, they would be computed as sequential. Each future still needs to be .awaited - but in order to be evaluated concurrently the program authors no longer have to figure it out by hand.

If we think about it, this feels like a natural evolution from the principles of async/.await. Just the syntax alone provides us with the ability to convert complex asynchronous callback graphs into seemingly imperative-looking code. And by extending that to concurrency too, we're able to reap even more benefits from it.

What about other concurrency operations?

A brief look at the futures-concurrency library will reveal a number of concurrency operations. Yet here we're only discussing one: Join. That is because all the other operations do something which is unique to async code, and so we have to write async code to make full use of it. Whereas join does not semantically change the code: it just takes independent sequential operations and runs them in concert.

Maybe-async and auto-concurrency

The main premise of #[maybe(async)] notations is that they can take sequential code and optionally run them without blocking. Under the system described in this post that code could not only be non-blocking, it could also be concurrent. Taking the system we're describing in the "Effect Generic Function Bodies and Bounds" draft, we could write our async let-based code example as follows to make it conditional over the async effect:


#![allow(unused)]
fn main() {
#[maybe(async)]  // <- changed `async fn` to `#[maybe(async)] fn`
fn make_dinner() -> SomeResult<Meal> {
    async let veggies = chop_vegetables();
    async let meat = marinate_meat();
    async let oven = preheat_oven(350);

    async let dish = Dish(&[veggies.await?, meat.await?]);
    oven.cook(dish.await, Duration::from_mins(3 * 60)).await
}
}

Which when evaluated synchronously would be lowered to the following code. This code blocks and runs sequentially, but that is the best we can do without async Rust's ad-hoc async capabilities.


#![allow(unused)]
fn main() {
fn make_dinner() -> SomeResult<Meal> {
    let veggies = chop_vegetables();
    let meat = marinate_meat();
    let oven = preheat_oven(350);

    let dish = Dish(&[veggies?, meat?]);
    oven.cook(dish, Duration::from_mins(3 * 60))
}
}

This is not the only way that #[maybe(async)] code could leverage async concurrency operations: an async version of const_eval_select would also work. It would, however, be by far the most convenient way of creating parity between both contexts. As well as make async Rust code that much easier to read.

A note on syntax

An earlier version of this document proposed using .co.await, .co_await, just .co or some other keyword to take the place of async let to indicate a concurrent .await can happen. The feasibility of syntax like that is not clear; though there would likely be distinct benefits to preserving the postfix nature of existing notations. Any further exploration of this direction should consider alternate syntaxes to async let. In particular as concurrent execution of for await loops is something that's also desirable, and would likely want syntax parity with concurrent execution of futures.

Conclusion

In this document we describe a mechanism inspired by Swift's async let primitive to author imperative-looking code which is lowered into concurrent, unmanaged futures. Rather than needing to manually convert linear code into a concurrent directed graph, the compiler could do that for us. Here is an example code as we would write it today using the Join::join operation, compared to a high-level async let based variant which would desugar into the same code.


#![allow(unused)]
fn main() {
/// A manual concurrent implementation using Rust 1.76 today.
async fn make_dinner() -> SomeResult<Meal> {
    let dish = {
        let veggies = chop_vegetables();
        let meat = marinate_meat();
        let (veggies, meat) = (veggies, meat).try_join().await?;
        Dish::new(&[veggies, meat]).await
    };
    let (dish, oven) = (dish, preheat_oven(350)).try_join().await?;
    oven.cook(dish, Duration::from_mins(3 * 60)).await
}

/// An automatic concurrent implementation using a hypothetical `async let`
/// feature. This would desugar into equivalent code as the manual example.
async fn make_dinner() -> SomeResult<Meal> {
    async let veggies = chop_vegetables();  // 1. concurrent with: 2, 3
    async let meat = marinate_meat();       // 2. concurrent with: 1, 3
    async let oven = preheat_oven(350);     // 3. concurrent with: 1, 2, 4

    async let dish = Dish(&[veggies.await?, meat.await?]);   // 4. depends on: 1, 2, concurrent with: 3
    oven.cook(dish.await, Duration::from_mins(3 * 60)).await // 5. depends on: 3, 4, not concurrent
}
}

This is not the first proposal to suggest an some form of concurrent notation for async Rust; to our knowledge that would be Conrad Ludgate in their async let blog post. However just like in Swift it seems to be based on the idea of managed multi-threaded tasks - not Rust's unmanaged, lightweight futures primitive.

A version of this is likely possible for multi-threaded code too; ostensibly via some kind of par keyword (par let / par for await..in). A full design is out of scope for this post; but it should be possible to improve Rust's parallel system in both async and non-async Rust alike (using tasks and threads respectively).

References

Unleakable Types

A trait-based system for unleakable types

In the Linear Types One-Pager post Yosh presented a system for types which cannot be leaked. This showed how by introducing a new auto-trait Leak, we could construct a system that would prevent types from being leaked.

By preventing types from being leaked, destructors would be guaranteed to run - which would give allow types in Rust to uphold linear type invariants. Meaning: destructors could be relied on for the purposes of safety, because some code will always be run when a type goes out of scope.

The way to think about this system is as follows:

  1. We define a new unsafe auto-trait named Leak
  2. All bounds take an implicit + Leak bound, like we do for + Sized.
  3. Certain functions such as mem::forget will always keep taking + Leak bounds.
  4. Functions which want to opt-in to linearity can take + ?Leak bounds.
  5. Types which want to opt-in to linearity can implement !Leak or put a PhantomLeak type in a field.

In code we could see this system expressed as follows:


#![allow(unused)]
fn main() {
// Define the trait and create a blanket impl for all types.
// The language would automatically add `+ Leak` bounds to all bounds.
auto trait Leak;
impl<T> Leak for T {}

// Types are by default assumed to be leakable.
struct Leakable;

// Mark a type as unleakable, guaranteeing destructors are run
struct Unleakable;
impl !Leak for Unleakable {}

// A function which requires types implement `Leak`.
// Here `T: Leak` bounds would be assumed by default.
fn will_leak<T>(value: T) {..}

// A function which operates on types which may or may not leak.
// We're using `?Leak` to opt-out of the automatic `+ Leak` bound.
fn may_leak<T: ?Leak>(value: T) -> T {..}
}

The limitation of auto-traits

In the challenges section of Linear Types One-Pager post, Yosh remarks the following:

We should look at an alternate formulation of these bounds by treating them as built-in effects. That would allow us to address the issues of versioning, visual noise, etc. in a more consistent and ergonomic way. But that's not a requirement to start testing this out.

The limitations of auto-traits are well-documented, and nobody would be excited by the prospect of introducing + ?Leak bounds to virtually every bound. For that reason there was a recommendation to explore alternate effect-based formulations instead.

A concrete example of a limitation for Leak as an auto-trait is provided by Saoirse in their posts "Changing the rules of Rust", "Follow up to "Changing the rules of Rust", and "Generic trait methods and new auto traits". They provide an example equivalent to the following:

#[edition = 2027]
crate may_leak {
    #[leak_compatible]  // ← allows bounds to optionally add `+ Leak`
    pub trait MayLeak {
        fn may_leak<T>(input: T);
    }
}

#[edition = 2024]
crate will_leak {
    pub struct WillLeak;
    impl super::may_leak::MayLeak for WillLeak {
        fn may_leak<T>(input: T) { // ← takes an implicit `+ Leak` bound
            core::mem::forget(input);
        }
    }
}

#[edition = 2027]
crate may_not_leak {
    struct Unleakable;
    impl !Leak for Unleakable {}
    pub fn may_not_leak<T: #[no_leak] super::may_leak::MayLeak>() { // ← disables the optional `+ Leak` bound
        T::may_leak(Unleakable);
    }
}

// The edition doesn't matter for this function.
fn main() {
    may_not_leak::may_not_leak::<will_leak::WillLeak>();
}

Under the rules we provided earlier, when we pass WillLeak to may_not_leak it should yield a compile-error. This ends up trying to pass a type which is !Leak to a function which takes an implicit + Leak bound, which shouldn't compile.

The limitations of this approach very clearly show up once we consider how this would be rolled out in practice. Every API which would want to types optionally leaking would need to add a + ?Leak or #[maybe(leak)] annotation to every parameter which may ever want to leak. This system mixes bespoke attributes together with auto-traits to create a system very similar to that of const.

Reformulating leaking as an effect

The system of #[leak_compatible] and #[no_leak] annotations presented is a bespoke encoding of the general system covered by effect generics. Fundamentally both effects and trait bounds can be in one of three states:

  • required: covered earlier by the implicit + Leak bound, and by effect T under effect generics.
  • optional: covered earlier by the #[leak_compatible] annotation, and #[maybe(effect)] under effect generics.
  • absent: covered by the earlier #[no_leak] annotation, and #[no(effect)] under effect-generics.

When discussing effects, they can either be opt-in (e.g. async, try) where we assume capabilities are not present unless we state we want them. Or opt-out (e.g. const) where we assume capabilities are present, and we opt-out of them go gain some other property. Currently Rust code may always leak, and what we're taking away is the ability to leak. Likely the right approach here would be to name the effect leak, and allow people to write both leak T and #[no(leak)] T. Under these rules the system would look like this:


#![allow(unused)]
fn main() {
// Mark a type as unleakable, guaranteeing destructors are run
#[not(leak)]
struct Unleakable;

// A type which may be leaked. `leak struct` is assumed,
// but can be written out for clarity.
struct Leakable;
leak struct Leakable;

// A function which requires all arguments can be leaked.
// `leak fn` is assumed by default, but may be written out for clarity.
fn will_leak<T>(value: T) {..}
leak fn will_leak<T>(value: T) {..}

// A function which operates on types which may or may not leak.
#[maybe(leak)]
fn may_leak<T>(value: T) -> T {..}
}

Applying this sytem to the longer example would look like this:

#[edition = 2027]
crate may_leak {
    #[maybe(leak)] // ← indicates this trait may of may not leak
    pub trait MayLeak {
        fn may_leak<#[maybe(leak)] T>(input: T); // ← indicates the type in this bound may or may not leak
    }
}

#[edition = 2024]
crate will_leak {
    pub struct WillLeak;
    impl super::may_leak::MayLeak for WillLeak {
        fn may_leak<T>(input: T) { // ← is assumed to be a `leak fn`; assumes `leak T`
            core::mem::forget(input);
        }
    }
}

#[edition = 2027]
crate may_not_leak {
    #[not(leak)] // ← this type may not be leaked
    struct Unleakable;

    #[not(leak)] // ← states all bounds take `#[no(leak)]`
    pub fn may_not_leak<T: super::may_leak::MayLeak>() {
        T::may_leak(Unleakable);
    }
}

// The edition doesn't matter for this function.
fn main() {
    may_not_leak::may_not_leak::<will_leak::WillLeak>();
}

While similar to the previous design, this version applies a consistent logic and naming to the bounds and ascriptions following the system laid out by effect-generics. This would make it so introducing linearity into the type system wouldn't be its own design with its own attributes, but part of a consistent framework by which we can evolve the language.

Changing defaults across editions

An alternative design for #[not(leak)] would be to follow the design of the const keyword more closely, and introduce a positive effect. Perhaps something like a linear T / linear fn. However if we assume we will eventually be successful in the transition to adoption linearity, this would put us in the awkward position where the ideal system would end up with more ascriptions.

The beauty of #[not(leak)] as the discriminant for linearity is that we could eventually change the default across editions to not assume leaking is provided, and only if you want to opt-in to being able to leak you have to add it to your functions. This is currently already the same for keywords such as async and gen. Under these rules, code would be able to change like this:


#![allow(unused)]
fn main() {
// All types are assumed to be unleakable by default
struct Unleakable;

// A type which may be leaked.
leak struct Leakable;

// A function which requires all types can be leaked.
leak fn will_leak<T>(value: T) {..}

// A function which operates on types which may or may not leak.
fn may_leak<T>(value: T) -> T {..}
}

Hypothetically a third kind of function could be described where all arguments are assumed not to leak. But just like we don't (yet?) have a clear use case for const-only functions, it's unclear that no-leak-only functions would be beneficial. That said, effect generic provides a consistent framework which would enable for these to be introduced.

On the choice of keywords

All keywords and syntax used in this post should be interpreted as placeholders only. It's hard to show examples if you don't name things, so we've picked some names to make the example easier to follow. The emphasis of this post is on the semantics of the system and showing how we can express linear types in a consistent way by leveraging the framework of effect generics.

References

πŸ“š Draft RFCs

The "Draft RFCs" are "end-user readable" documentation that explains how to use the feature being deveoped by this initiative. If you want to experiment with the feature, you've come to the right place. Until the feature enters "feature complete" form, the Draft RFCs should be considered a work-in-progress.

Summary

This RFC introduces Effect-Generic Trait Declarations. These are traits which are generic over Rust's built-in effect keywords such as async. Instead of defining two near-identical traits per effect, this RFC allows a single trait to be declared which is generic over the effect. Here is a variant of the Into trait which can be implemented as either async or not.


#![allow(unused)]
fn main() {
#[maybe(async)]
trait Into<T>: Sized {
    #[maybe(async)]
    fn into(self) -> T;
}
}

Implementers can then choose whether to implement the base version or the effectful version of the trait. If they want the base version they don't include the async effect. If they want the async version they can include the async keyword.


#![allow(unused)]
fn main() {
/// The base implementation
impl Into<Loaf> for Cat {    // The trait is not marked async…
    fn into(self) -> Loaf {  // and thus neither is the method.
        self.nap()
    }
}

/// The async implementation
impl async Into<AsyncLoaf> for AsyncCat {  // The trait is marked async…
    async fn into(self) -> AsyncLoaf {     // and thus so is the method.
        self.async_nap().await
    }
}
}

Motivation

Rust is a single language that's made up of several different sub-languages. There are the macro languages, as well as the generics language, patterns, const, unsafe, and async sub-languages. Rust works anywhere from a micro-controller to Windows, and even browsers. One of the biggest challenges we have is to not only keep the language as easy to use as we can, it's to ensure it works relatively consistently on all the different platforms we support.

We're currently in the process of adding support for the const and async language features to Rust. But we're looking at various other extensions as well, such as generator functions, fallible functions, linearity, and more. These are really big extensions to the language, whose implementation will take on the order of years. If we want to successfully introduce these features, they'll need to be integrated with every other part of the language. As well as having wide support in the stdlib.

Effect Generic Trait Declarations are a minimal language feature which enable traits to add support for new effects, without needing to duplicate the trait itself. So rather than having a trait Into, TryInto, AsyncInto, and the inevitable TryAsyncInto - we would declare a single trait Into once, which has support for any combination of async and try effects. This is backwards-compatible by design, and should be able to support any number of effect extensions we come up with in the future. Ensuring the language can keep evolving to our needs.

Guaranteeing API consistency

Evolving a programming language and stdlib is pretty difficult. We have to pay close attention to details. And in Rust specifically: once we make a mistake it's pretty hard to roll back. And we've made mistakes with effects in the past, which we now have to work with 1.

1

In Rust 1.34 we stabilized a new trait: TryInto. This was supposed to be the fallible version of the Into trait, containing a new associated type Error. However since Rust 1.0 we've also had the FromStr trait, which also provides a fallible conversion, but has an associated type Err. This means that when writing a fallible conversion trait, it's unclear whether the associated type should be called Err or Error.

This might seem minor, but without automation these subtle similar-but-not-quite-the-same kinds of differences stand out. The only way to ensure that different APIs in different contexts work consistently is via automation. And the best automation we have for this is the type system.

Guide-level explanation

Trait definitions

The base of Effect Generic Trait Declarations is the ability to declare traits as being generic over effects. This RFC currently only considers the async effect, but should be applicable to most other effects (modulo unsafe and const, more on that later). The way a trait is defined is by adding a #[maybe(effect)] notation. This signals that a trait may be implemented as carrying the effect. For example, a version of the Read trait which may or may not be async would be defined as:


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait Read {
    #[maybe(async)]
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
}
}

Trait implementations

Traits can be implemented as either async or non-async. The trait-level #[maybe(async)] can be thought of as a const-generic bool which determines the value of the method-level #[maybe(async)] declarations. So if a trait is implemented as async, all methods tagged as #[maybe(async)] have to be async too.


#![allow(unused)]
fn main() {
/// The base implementation
impl Read for Reader {
    fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
        // ...
    }
}

/// The async implementation
impl async Read for AsyncReader {
    async fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
        // ...
    }
}
}

Method markers

This RFC only covers trait methods which carry an effect or not. It does not cover types which may or may not have effects. The intent is to add this via a future extension, so for the scope of this RFC we have to be able to declare certain methods as not being generic over effects. This is the default behavior; no extra annotations are needed for this.

Taking the Read trait example again; the chain method returns a type Chain which implements Iterator. Accounting for the chain method, the declaration of Read would be:


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait Read {
    ...

    // This method is not available for `impl async Read`
    fn chain<R: Read>(self, next: R) -> Chain<Self, R>
       where Self: Sized { .. }
}
}

Because chain is not marked as maybe(async), when implementing async Read, it will not be available. If a synchronous method has to be available in an async context, it should be possible to mark it as not(async), so that it's clear it's part of the API contract for the async implementation - and is never async.


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait Read {
    ...

    // This method would be available for `impl async Read`
    #[not(async)]
    fn chain<R: Read>(self, next: R) -> Chain<Self, R>
       where Self: Sized { .. }
}
}

Reference-level explanation

Effect lowering

At the MIR level the lowering of #[maybe(effect)] is shared with const, and is essentially implemented via const generic bools. Take the following maybe-async definition of Into:


#![allow(unused)]
fn main() {
// Trait definition
#[maybe(async)]
trait Into<T>: Sized {
    #[maybe(async)]
    fn into(self) -> T;
}
}

At the type level the #[maybe(async)] system is lowered to a const bool which determines whether the function should be async. If the trait is implemented as async, the bool is set to true. If it isn't, it's set to false.


#![allow(unused)]
fn main() {
// Lowered trait definition
trait Into<T, const IS_ASYNC: bool = false>: Sized {
    type Ret = T;
    fn into(self) -> Self::Ret;
}
}

By default the const bool is set to false. The return type of the function here is the base return type of the definition:


#![allow(unused)]
fn main() {
/// The base implementation
impl Into<Loaf> for Cat {
    fn into(self) -> Loaf {
        self.nap()
    }
}

// Lowered base trait impl
impl Into<Loaf, false> for Cat { // IS_ASYNC = false
    type Ret = T;
    fn into(self) -> Self::Ret {
        self.nap()
    }
}
}

However if we implement the async version of the trait things change a little. In the lowering the const bool is set to true to indicate we are in fact async. And in the lowering we wrap the return type in an impl Future, as well as return an anonymous async {} block from the function.


#![allow(unused)]
fn main() {
/// The async implementation
impl async Into<AsyncLoaf> for AsyncCat {
    async fn into(self) -> AsyncLoaf {
        self.async_nap().await
    }
}

// Lowered async trait impl
impl Into<AsyncLoaf, true> for AsyncCat { // IS_ASYNC = true
    type Ret = impl Future<Output = T>;
    fn into(self) -> Self::Ret {
        async move {
            self.async_nap().await
        }
    }
}
}

Effect lowering with lifetimes

Things become more interesting when lifetimes are involved in the effectful lowering of a trait. The return type of an async fn which takes a reference has to be a future with a lifetime. Which means it's in our lowering our associated type can't be a plain future - it has to be a future with a lifetime attached. And this requires lifetime GATs to work.

Say instead of the async version of Into, we tried to write the maybe-async version of AsRef 2. We could define it as follows:

2

this is just for the purpose of an example; I don't actually know of any cases which want an async version of AsRef. But never say never.


#![allow(unused)]
fn main() {
/// The trait definition
#[maybe(async)]
pub trait AsRef<T>
where
    T: ?Sized,
{
    #[maybe(async)]
    fn as_ref(&self) -> &T;
}

/// The lowering of the trait definition
pub trait AsRef<T, const IS_ASYNC: bool = false>
where
    T: ?Sized,
{
    type Ret<'a> = &'a T
        where Self: 'a;
    fn as_ref(&self) -> Self::Ret<'_>;
}
}

We could then implement it like we did with our Into impl. The non-async impl would look like this:


#![allow(unused)]
fn main() {
/// The base implementation
impl AsRef<Loaf> for Cat {
    fn as_ref(&self) -> &Loaf {
        self.nap_ref()
    }
}

/// Lowering of the base implementation
impl AsRef<Loaf, false> for Cat { // IS_ASYNC = false
    type Ret<'a> = &'a Loaf
        where Self: 'a;
    fn as_ref(&self) -> Self::Ret<'_> {
        self.nap_ref()
    }
}
}

And the async implementation would look like this:


#![allow(unused)]
fn main() {
/// The base implementation
impl async AsRef<Loaf> for AsyncCat {
    async fn as_ref(&self) -> &Loaf {
        self.async_nap_ref().await
    }
}

/// Lowering of the base implementation
impl AsRef<AsyncLoaf, true> for AsyncCat { // IS_ASYNC = true
    type Ret<'a> = impl Future<Output = &'a Loaf> + 'a
        where Self: 'a;
    fn as_ref(&self) -> Self::Ret<'_> {
        async {
            self.async_nap_ref().await
        }
    }
}
}

While effect-generic trait definitions with lifetimes do rely on GATs in their lowering, crucially they don't rely on any potential notion of lifetime-generics to function. The right lifetime GATs can be emitted by the compiler during lowering, and should therefor always be accurate.

Effect states

This RFC reasons about effects as being in one of four logical states:

  • Always: This is when an effect is always present. For example: if a function implements some kind of concurrency operations, it may always want to be async. This is signaled by the existing meaning of the async fn notation.
  • Maybe: This is when an effect may sometimes be present. This will apply to most traits in the stdlib. For example, if we want to write an async version of the Read trait its associated methods will also want to be async.
  • Not: This is when an effect is never present. For example: Iterator::size_hint will likely never want to be async, even if the trait and most methods are async. In order for methods to be available in the effectful implementatin of the trait, they have to be marked as never carrying the effect.
  • Unknown: Methods which haven't explicitly declared which logical state they're in are unknown. This is a distinct state from not, because a method may be converted from unknown to maybe without breaking backwards compatibility.

For the async effect methods which are always async are labeled async fn. Methods which may or may not be async are labeled #[maybe(async)]. Methods which are never async are labeled #[not(async)]. All other methods are unlabeled, and are not made available to the async implementation of the trait.

Concrete impls and coherence

With the eye on forward-compatibility, and a potential future where types can themselves also be generic over effects, for now types may only implement either the effectful or the base variant of the trait. This ensures that the door is kept open for effect generic implementations later on. As well as ensures that during trait selection the trait variant remains unambiguous. The diagnostics for this case should clearly communicate that only a single trait variant can be implemented per type.

error[E0119]: conflicting implementations of trait `Into` for type `Cat`
 --> src/lib.rs:5:1
  |
4 | impl Into for Cat {}
  | ----------------- first implementation here
5 | impl async Into for Cat {}
  | ^^^^^^^^^^^^^^^^^ conflicting implementation for `Cat`
  |
  | help: types can't both implement the sync and async variant of a trait

Trait bounds

Using effect generic trait definitions in trait bounds should be no problem, assuming the bounds are concrete. Unlike concrete types, generic bounds may implement both effecful and uneffectful implementations for the same bounds as long as they target non-overlapping sets of traits. For example, assuming we had a maybe-async version of Into, introducing a maybe-async version of From would allow us to write the following non-overlapping generic bounds.


#![allow(unused)]
fn main() {
/// If we also introduce a maybe-async
/// version of the `From` trait…
#[maybe(async)]
pub trait From<T>: Sized {
    #[maybe(async)]
    fn from(value: T) -> Self;
}

/// …we can implement the synchronous
/// variant for any type `T, U: From<T>`…
impl<T, U> Into<U> for T
where
    U: From<T> {}

/// …as well as the asynchronous variant for
/// any type `T, U: async From<T>`.
impl<T, U> async Into<U> for T
where
    U: async From<T> {}
}

For the purpose of the trait resolver, From and async From should be considered non-overlapping bounds. This is a new capability which we'll need to introduce, and effectively comes down to treating U: From<T, false> and U: From<T, true> as non-overlapping bounds. Effect-generic trait bounds (conditional effects in bounds) are not introduced by this RFC, but may be introduced by a future extension.

Super traits

Super-trait hierarchies should be supported, as long as they are appropriately annotated. Say we wanted to define a maybe-async version of BufRead which has Read as a supertrait. For that to work, the Read trait would also need to be marked maybe-async. That way if we implement the async version of BufRead we also require the async version of Read - idem for the non-async variants.


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait BufRead: #[maybe(async)] Read {
    #[maybe(async)]
    fn fill_buf(&mut self) -> Result<&[u8]>;
    #[maybe(async)]
    fn consume(&mut self, amt: usize);
}
}

If a trait wants to have a non-async super-trait, it has to mark the super-trait as not being async. In the case that the supertrait eventually becomes generic over an effect, it's clear from the beginning which variant we 've chosen.


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait SuperTrait {}

#[maybe(async)]
pub trait SubTrait: #[not(async)] SuperTrait { }
}

Certain traits may want to guarantee ahead of time that they will never support a certain effect. For these traits it is possible to omit the effect marker, as the state of the effect is already unambiguous. It is expected most marker traits will want to be unambiguously never support for example the async effect.


#![allow(unused)]
fn main() {
// The trait `Sized` guarantees it
// will not ever be an `async trait`… 
#[not(async)]
trait Sized {}

// …which means it does not require annotations
// when used as a supertrait.
#[maybe(async)]
trait Into<T>: Sized { .. }
}

TODO: prerequisites

  • associated type defaults
  • complex where bounds on associated items removing the need for them to get implemented
  • a working demo of the constness effect
  • T-types buy-in (not before the old solver got removed)

Drawbacks

Const effect states

The const keyword in Rust has two meanings:

  • const {} blocks are always const-evaluated ("always" semantics)
  • const fn functions may be const evaluated ("maybe" semantics)

Notably const does not provide a way to declare functions which must always const-evaluated. This RFC determines all traits and methods can be in one of four [states][#effect-states], including "always async" and "maybe async". As a result declaring a function which is "maybe-async" will syntactically appear different from a function which is "maybe-const".

TODO: Additional syntax

  • we're adding some new syntax, that's going to be A Thing

TODO: Direction

  • while not inherently closing any doors, we are kind of committing to the idea that we want to extend the stdlib to be effectful - that's the point
  • this has repercussions for how we structure our base traits and interfaces too

Prior art

TODO:

  • swift: async polymorphism + rethrow
  • c++: noexcept + constexpr
  • koka: effect handlers (free monad)
  • rust: const fn
  • zig: maybe async functions

Unresolved questions

  • may want to use an associated const instead of a const generic

TODO: Syntax

  • #[maybe(async)] is a placeholder
  • maybe(async) is clear but is verbose
  • ?async is sigil-heavy, but has precedence in the trait system
  • ~async is sigil-heavy, and also reserves a new sigil
  • if/else at the trait level does not create bidirectional relationships
  • async<A> is less clear and verbose

Alternatives

TODO: Do nothing (null hypothesis)

  • effect differences are inherent, which means they have to be solved somewhere
  • effect composition is where it gets bad; we have an async version of the stdlib, not an async + fallible version
  • things like linearity seem quite far out of reach right without this

Future possibilities

TODO: Integration with other keywords

  • fallible functions
  • generator functions
  • linearity

TODO: Effect-generic types and bodies

  • types
  • functions

#![allow(unused)]
fn main() {
/// Before: the base implementation
impl Into<Loaf> for Cat {
    fn into(self) -> Loaf {
        self.nap()
    }
}

/// Before: the async implementation
impl async Into<AsyncLoaf> for AsyncCat {
    async fn into(self) -> AsyncLoaf {
        self.async_nap().await
    }
}
}

#![allow(unused)]
fn main() {
// After: a single implementation
...
}

TODO: Effect sets

  • named effect sets
  • unify core and std via sets

TODO: Normalize const

  • const fn is maybe-const
  • const {} is always const
  • this is super annoying lol, and that's why this system doesn't work for const right now

Summary

RFC 0000 introduces traits which are generic over an effect, but implementers have to pick whether they want to implement the base version or the effectful version of the trait. This RFC extends that system further by removing that limitation, and enabling authors to write functions which themselves are generic over effects. For example, here is a function io::copy which would be able to operate either synchronously or asynchronously, depending on which types are passed to it.


#![allow(unused)]
fn main() {
/// This defines a trait `Read` which may or may not
/// be async, using the design introduced in RFC 0000.
#[maybe(async)]
pub trait Read {
    #[maybe(async)]
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;
}

/// This defines a trait `Write` which may or may not
/// be async, using the design introduced in RFC 0000.
#[maybe(async)]
pub trait Write {
    #[maybe(async)]
    fn write(&mut self, buf: &[u8]) -> Result<usize>;
    #[maybe(async)]
    fn flush(&mut self) -> Result<()>;
}

/// This defines a function `copy` which copies bytes from a
/// reader into a writer. This RFC enables this function to
/// operate either synchronously or asynchronously. Where if
/// operating synchronously, the `.await` operator becomes a
/// no-op.
#[maybe(async)] 
pub fn copy<R, W>(reader: &mut R, writer: &mut W) -> Result<()>
where
    R: #[maybe(async)] Read + ?Sized,
    W: #[maybe(async)] Write + ?Sized,
{
    let mut buf = vec![0; 1024];
    loop {
        match reader.read(&mut buf).await? {
            0 => return Ok(()),
            n => writer.write(&mut buf[0..n]).await?,
        };
    }
}
}

Motivation

RFC 0000 introduced effect-generic trait definitions: traits which are generic over effects, but implementors of the trait have to pick which version they implement. This works fine when authors know which effects they will be working with, like in applications. But library authors will often want to write code which not only works with one effect, but any number of effects. And for that effect-generic functions and bounds would greatly help reduce the amount of code duplication.

The blog post: "The bane of my existence: Supporting both async and sync code in Rust" documents the negative experience of one of the rspotify authors maintaining both sync and async versions of a crate. With ecosystem crates such as maybe_async and async-generic attempting to provide mitigations via the macro system. But these crates are limited in what they can provide when it comes to integrating with Rust's libraries, diagnostics, tooling, and inference systems.

rspotify is a thoroughly documented example of an author wanting to be generic over an effect, but there are others. Specifically for the async effect, the mongodb crate has both sync and async variants. So does the postgres crate [sync, async], as well as the reqwest crate [sync, async], and both the tokio and async-std crates duplicate large swaths of the stdlib's functionality. Other effects are also covered, such as fallible-iterator for a version of the stdlib's Iterator trait which short-circuits on error. And fallible_vec for a Vec type with methods which may returns errors rather than panics if an allocation fails.

Guide-level explanation

Effect-generic functions

Effects such as async, try, or gen define a superset of the language. With some minor exceptions, they provide access to more features than functions which don't have the effect. However, to ensure the effect forwards correctly through function bodies, we require some degree of annotations. In the case of the async effect, we require function calls to be suffixed with .await. In the case of try, we require function calls to be suffixed with ?. This is called "effect forwarding".

Say we wanted to write a function sum which takes an impl Iterator<Item = u32> and sums all numbers together. If we included the trait definition, we could write it like so:


#![allow(unused)]
fn main() {
/// The `Iterator` trait
pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

/// Iterate over all numbers in the
/// iterator and sum them together
pub fn sum<I: Iterator<Item = u32>>(iter: I) -> u32 {
    let mut total = 0;
    while let Some(n) = iter.next() {
        total += n;
    }
    total
}
}

This works fine for non-async code. In fact: this is almost exactly how the stdlib versions of Iterator::sum is defined. But this code has a limitation: the underlying iterator will always block between calls to next. To resolve that, next should add support for async/.await, which we can do by adding the #[maybe(async)] notation.


#![allow(unused)]
fn main() {
/// The `Iterator` trait with optional
/// support for the `async` effect
#[maybe(async)]
pub trait Iterator {
    type Item;
    #[maybe(async)]
    fn next(&mut self) -> Option<Self::Item>;
}
}

Nothing too exciting is going on yet. This is all uses the mechanisms we defined in RFC 0000, and adding support for async/.await just required some extra #[maybe(async)] annotations. This becomes more interesting once we start looking to not only allow traits to be generic over the async effect, but functions and trait bounds as well. The way we can do that is fairly mechanical: all we have to do is add some extra #[maybe(async)] notations to the function signature, and some extra .awaits inside the function body.


#![allow(unused)]
fn main() {
/// Iterate over all numbers in the
/// iterator and sum them together
#[maybe(async)]                                // 1. async
pub fn sum<I>(iter: I) -> u32
where
    I: #[maybe(async)] Iterator<Item = u32>    // 2. async
{
    let mut total = 0;
    while let Some(n) = iter.next().await {    // 3. .await
        total += n;
    }
    total
}
}

In this example we've added additional #[maybe(async)] notations at comments 1, and 2. And in the function body added additional .await points at comment 3. What's key here is that if we remove the async and await notations, we end up back with a perfectly valid non-async code. And that's basically the way the system works under the hood: when compiled as async, the .await points signal suspension points. While when the function is compiled as non-async, you can think of the .await points as immediately returning if they suspend.

In order to make this system work though, we have to apply some rules. The first rule is that #[maybe(async)] functions can't directly call async functions. Remember: our function needs to be able to strip away the .await points and still compile. If a function is always async, then removing the .await points would leave us with an invalid function, so that's not allowed.

The second rules is: maybe-async functions may or may not return futures. So we can't treat the return type like a concrete future which we can freely pass around. That means that in #[maybe(async)] contexts, the only valid thing to do with futures is to .await immediately them.

Using effect-specific behavior

Not being able to treat futures as first-class items in #[maybe(async)] functions might seem like a pretty big restriction: half the reason to use async/.await in the first place is to be able to concurrently execute computations. But there is a direct way out for us here: intrinsic-based specialization.


#![allow(unused)]
fn main() {
fn runtime() -> i32 { 1 }
const fn compiletime() -> i32 { 1 }
unsafe { const_eval_select((), compiletime, runtime) }
}

const functions can specialize their behavior using the const_eval_select intrinsic. Depending on whether execution is occurring during compilation or at runtime, different code will be run. For async and other effects, we'll be providing a similar intrinsic. Depending on whether a function is compiled as async or not, different code will be run.


#![allow(unused)]
fn main() {
fn not_async() { println!("hello sync"); }
async fn yes_async() { println!("hello async"); }
unsafe { async_eval_select((), not_async, yes_async) }
}

Within the async function it would be possible to freely operate on futures as first-class items: freely applying concepts such as concurrency, cancellation, and combinations of the two. In the future we may choose to expose similar functionality via a first-class language feature instead. See the future possibilities section for a discussion of this.

Effect selection and inference

While functions can be written as generic over effects, when they're finally compiled the compiler needs to know which variant to use. In most cases the compiler should be able to infer this unambiguously from the context. When a #[maybe(async)] function is called from an async context, and the is then .awaited - the compiler can be pretty certain we're interested in the async version of the function.

#[maybe(async)]
fn meow() {
    println!("meow");
}

async fn main() {
    meow().await; // `fn meow` can be inferred to be `async`
}

If the presence - or absence - of .await calls isn't enough to inform the compiler, it will look at whether the enclosing context is async or not. And if that's not enough to unambiguously figure out which variant to select, it will always be possible to explicitly tell the compiler which variant we expected using turbofish notation.

#[maybe(async)]
fn meow() {
    println!("meow");
}

async fn main() {
    let fut = meow::<async>();   // `fn meow` is async
    meow::<#[not(async)]>();     // `fn meow` is not async
}

Effect generic provided trait methods

This RFC not just enables free functions to be generic over effects: it also enables provided trait methods to work with effect generics. Take for example our maybe-async trait Read again. By default it provides a number of methods, including read_to_end. With effect generic trait definitions, those provided functions can be generic over effects, meaning they can be made available to the effectful and base variants of the trait alike.


#![allow(unused)]
fn main() {
#[maybe(async)]
pub trait Read {
    // Using RFC 0000 required trait methods gained
    // support for `#[maybe(effect)]` annotations.
    #[maybe(async)]
    fn read(&mut self, buf: &mut [u8]) -> Result<usize>;

    // With this RFC, entire functions can be made
    // generic over effects, meaning provided functions
    // in traits now also work with `#[maybe(effect)]`.
    #[maybe(async)]
    fn read_to_end(&mut self, buf: &mut Vec<u8>) -> Result<usize> { .. }
}
}

Reference-level explanation

TODO: Lowering

NOTE: this section is incomplete and in-progress. While we know it is possible because we have implemented a working version of this outside of the compiler, there are changes in the compiler happening which means this section may change. Once those changes have landed, this section should be rewritten to match that. Until that time please consider this section incomplete and subject to change.

Let's continue with our earlier example of the trait Iterator and the function sum which both have conditional support for the async effect via #[maybe(async)]. In its base form the trait Iterator looks like this:


#![allow(unused)]
fn main() {
/// The `Iterator` trait
#[maybe(async)]
pub trait Iterator {
    type Item;
    #[maybe(async)]
    fn next(&mut self) -> Option<Self::Item>;
}
}

Which using the desugaring proposed in RFC 0000, would desugar to a trait with a const-generic bool which determines whether it is async or not:


#![allow(unused)]
fn main() {
// Lowered trait definition
pub trait Iterator<const IS_ASYNC: bool = false> {
    type Item;
    type Ret = Option<Self::Item>;
    fn next(&mut self) -> Ret;
}
}

We've seen how when maybe-async traits are implemented as sync they return their base type, and when implemented as async they wrap that up in an impl Future - see RFC 0000's section on "Lowering" for more details. Now for our function body, the base definition looks like this:


#![allow(unused)]
fn main() {
#[maybe(async)]
pub fn sum<I>(iter: I) -> u32
where
    I: #[maybe(async)] Iterator<Item = u32>
{
    let mut total = 0;
    while let Some(n) = iter.next().await {
        total += n;
    }
    total
}
}

TODO: ask oli for more details about the desugaring. Ref is: https://github.com/yoshuawuyts/maybe-async-channel/blob/2fc6fa012830525482d62a8facfae5e5a5e762fe/maybe-async-std/src/lib.rs#L47-L54

Carried effects as non-destructive code transformations

The reason why this RFC is able to write function bodies which are generic over effects is because effects such as async are non-destructive. Adding the async notation to a function does not lose any information - meaning you can arrive at the function signature and body you might had had before simply by removing the async and .await notation.

Take this simple async function. It calls the method meow on some type cat, returning String.


#![allow(unused)]
fn main() {
/// 1. Our base `meow` function
fn meow() -> String {
    cat.meow()
}
}

Say cat.meow was async instead. We could change our function to support it simply by adding the necessary async and .await notations.


#![allow(unused)]
fn main() {
/// 2. The async version of `meow`
async fn meow() -> String {
    cat.meow().await
}
}

Because adding async and .await does not erase any information from the base function, it is non-destructive. Meaning we can always reverse it by removing all the calls to async and .await, arriving back at the function we initially had.


#![allow(unused)]
fn main() {
/// 3. Stripping the `async/.await` notations
/// yields our base function again
fn meow() -> String {
    cat.meow()
}
}

Uncarried effects such as const don't require any forwarding notations, and so are by definition non-destructive in their transformation. In addition to async, Rust has two other carried effects: gen and try. Parts of both of these effects are unstable or undecided, but there is no reason we should require their notation to be destructive. Given the unstable nature of these effects, we'll cover them in more detail in the "future possibilities" section of this RFC.

effect nameforwarding notationdesugaringlogical return typecarried type
async.awaitimpl Future<Output = T>T!
try†?impl Try<Output = T, Residual = R>†TR
gen†yield from ‑impl Iterator<Item = U>()U

† These items exist in Rust, but are unstable.

‑ These items have been discussed for inclusion, but have not yet been included on nightly.

"logical return type" in this context means: the type the function returns after the function has been evaluated and any forwarding notation has been applied. The "carried type" here refers to the additional types end-users need to be aware of when the effect is introduced. For example, when using the try effect, users will be exposed to additional Result<_, E> or Option types.

TODO: Unambiguous variant selection

  • copy::<async>(read, writer).await?;

TODO: Effect-generic bodies logic

caller does not have effectcaller may have an effectcaller always has effect
callee does not have effectβœ… allowed to evaluateβœ… allowed to evaluateβœ… allowed to evaluate
callee may have effectβœ… allowed to evaluateβœ… allowed to evaluateβœ… allowed to evaluate
callee always has effect❌ not allowed to evaluate❌ not allowed to evaluateβœ… allowed to evaluate

Evaluating an async function in a non-async context is not possible.


#![allow(unused)]
fn main() {
//! Caller context does not have an effect,
//! callee always has an effect

async fn callee() {}
fn caller() {
    callee().await // ❌ cannot call `.await` in non-async context
}
}

The caller's context may be evaluated as synchronous, but the callee is guaranteed to always be asynchronous. Because as we've seen it's not possible to evaluate async functions in non-async contexts.


#![allow(unused)]
fn main() {
//! Caller context may have an effect,
//! callee always has an effect

async fn callee() {}
#[maybe(async)]
fn caller() {
    callee().await // ❌ cannot call `.await` in maybe-async context
}
}

TODO: effect-row polymorphism

  • the effect for all members in a function is the same bound
  • if you mix async + non-async, both have to be async to work
  • but that's generally ok: there's a subtyping relationship possible, so even if we don't do it automatically we can just do it ourselves

TODO: Prerequisites

  • ask oli about which compiler features we're missing to implement this

Drawbacks

Limits the future effects we can add

  • Being able to add N new carried effects for various different purposes is out of the cards
  • But we're specifically fine with the carried effects we have, uncarried effects are more interesting as they provide more features by constraining the design space
  • Arbitrary user-defined effects can likely be defined by contexts/capabilities + yield

Rationale and alternatives

TODO: Don't require forwarding notation

  • important though; as that's where control flow may happen
  • the possibility of something happening is the entire point of annotating it

TODO: Flattened compositional hierarchy

  • requires a do notation / .await? / ?.await become a single operation
  • ends up with a single Coroutine uber trait from which all other traits are derived
  • only covers carried effects, not uncarried ones
  • unclear how it would enable effect-generic functions to be authored
  • results in a system of trait aliases

TODO: sans-io

  • yeah sans-io is cool
  • but it depends on passing things like impl Read rather than directly calling File::open
  • this means taking maybe-async interfaces, and so we need maybe-async logic
  • ergo: while a good idea, it's not an alternative

TODO: TLS-preserving closures

  • unclear how we would combine scope escapes with the borrow checker
  • threading through effects + forwarding notations through all call sites achieves the same effect
  • main challenge is backwards-compat, but effect generics address that

Prior art

const fn

Using the const keyword this is already possible in Rust: a single const fn function can both be evaluated during compilation and at runtime. Contrast this to const {} blocks, which can only be evaluated at compile-time. And using the const_eval_select intrinsic it will even be possible to provide different implementations depending on whether the function is evaluated at runtime or during compilation. This enables the runtime variant of a function to provide more optimized implementations, for example by leveraging platform-specific SIMD capabilities.

Unresolved questions

Future possibilities

TODO: try/? contexts

  • is already non-destructive
  • just unstable right now

TODO: gen/yield from contexts

  • Recognize that the return type is not the yield type
  • An additional yield from-alike syntax would be helpful here
    • preferred notation: for yield..in expr;

TODO: Composition of gen/yield from, async/.await and try/?

  • 2/3 of these effects have unstable components
  • but they would compose Just Fine
  • this should be its own RFC though

TODO: Arbitrary user-defined carried effects

  • composition of yield, contexts/capabilities, and concrete types
  • a handler can be expressed as a context or capability
  • we can yield N values to it by passing it a generator function
  • See also: "capabilities: effects for free" which applies this idea
  • Removes the need for arbitrary built-in control-flow effects

Archive

Effects in Rust

Rust has a number of built-ins which sure look a lot like effects. In this section we cover what those are, how they're in use today, touch on some of the pain-points experienced by them.

What do we mean by "effect" in this section?

For the purpose of this section we're considering effects in the broadest terms: "Any built-in language mechanism which triggers a bifurcation of the design space". This means: anything which causes you to create a parallel, alternate copy of the same things is considered an effect in this space.

This is probably not the definition we'll want to use in other sections, since effects should probably only ever apply to functions. In this section we're going to use "effect" as a catch-all term for "things that sure seem effect-y". When discussing effects we'll differentiate between:

  • Scoped Effects: which are effects which apply to functions and scopes, such as async fn which are reified as traits or types such as impl Iterator.
  • Data-Type Effects: which are Effects which apply to data types, encoded as auto-traits. For example: the Send auto-trait is automatically implemented on structs as long as its contained types are Send, and marks it as "thread-safe".

Asynchrony (Scoped Effect)

Description

Asynchrony in Rust enables non-blocking operations to be authored in an imperative fashion. This can be helpful for performance reasons, but feature-wise it enables "arbitrary concurrency" and "arbitrary cancellation" of computations. These can in turn be composed and leveraged by higher-level control-flow primitives such as "arbitrary timeouts" and "arbitrary parallel execution".

Asynchrony in Rust is implemented using a pair of keywords. async is used to create an async context which is reified into a state machine backed by the Future trait. And .await is used on the call-site to access the values inside of an async context. Because .await can only be called inside of async contexts, it eventually needs to be consumed by a top-level function which knows how to run a future to completion.

Feature Status

async/.await in Rust is considered "MVP stable". This means the reification of the effect is stable, and both the async and .await keywords exist in the language, but not all keyword positions are available yet.

Feature categorization

PositionSyntax
Effectasync
YieldN/A
Apply.await
Consumethread::block_on †, async fn main ‑
Reificationimpl Future

† thread::block_on is not yet part of the stdlib, and only exists as a library feature. An example implementation can be found in the Wake docs.

‑ async fn main is not yet part of the language, and only exists as a proc-macro extension as part of the ecosystem. It chiefly wraps the existing fn main logic in a thread::block_on call.

Positions Available

PositionAvailableExample
Manual trait implβœ…impl Future for Cat {}
Free functionsβœ…async fn meow() {}
Inherent functionsβœ…impl Cat { async fn meow() {} }
Trait methods⏳trait Cat { async fn meow() {} }
Trait declarations❌async trait Cat {}
Block scopeβœ…fn meow() { async {} }
Argument qualifiers❌fn meow(cat: impl async Cat) {}
Data types β€ βŒasync struct Cat {}
Drop β€ βŒimpl async Drop for Cat {}
Closures❌async Η€Η€ {}
Iterators❌for await cat in cats {}

† In non-async Rust if you place a value which implements Drop inside of another type, the destructor of that value is run when the enclosing type is destructed. This is called drop-forwarding. In order for drop-forwarding to work with async drop, some form of "async value" notation will be required.

Refinements

ModifierDescription
cancellation-safeHas no associated future-local state

Cancellation-Safe Futures

"cancellation-safety" is currently more like a term of art than an first-class term. It is a property used and relied upon by ecosystem APIs, but it is not represented in the type system anywhere. Which means APIs which rely on "cancellation-safety" do so without compiler-backing, which makes them a notorious source of bugs. This should probably be fixed, and when we do we probably will not want to call it "cancellation-safety" since it relates less to "cancellation" and more to the statefulness of futures, and whether or not they can be recreated without side-effects or data loss.

Fused Futures

A FusedFuture super-trait also exists, but it does not meaningfully feel like a modifier of the "async" effect. It only adds an is_terminated method which returns a bool. It does not inherently change the semantic functioning of the underlying Iterator trait, or enhance it with behavior which is otherwise absent. This is different from e.g. FusedIterator which says something about the behavior of the Iterator::next function.

It's also worth noting that the FusedFuture trait is mostly useful for the select! control-flow construct. Without that, FusedFuture would likely not see much use (ref).

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

Compile-time Execution (Scoped Effect)

Description

The const keyword marks functions as "is allowed to be evaluated during compilation". When used in scope position its meaning changes slightly to: "this will be evaluated during compilation". There is no way to declare "must be evaluated at compilation" functions, causing the meaning of "const" to be context-dependent.

declarationusage
keyword never appliesfn meow() {}fn hello() { meow() }
keyword always applies-const CAT: () = {};
keyword conditionally appliesconst fn meow() {}const fn hello() { meow() }

Feature Status

The const feature is integrated in a lot of the stdlib and ecosystem already, but it's notoriously missing any form of const-traits. Because a lot of Rust's language features make use of traits, this means const contexts have no access to iteration, Drop handlers, closures, and more.

Feature categorization

PositionSyntax
Effectconst fn
YieldN/A
Applyautomatic
Consumeconst {}, const X: Ty = {}
ReificationN/A

Positions Available

PositionAvailableExample
Manual trait impl❌N/A
Free functionsβœ…const fn meow() {}
Inherent functionsβœ…impl Cat { const fn meow() {} }
Trait methods⏳trait Cat { const fn meow() {} }
Trait declarations❌const trait Cat {}
Block scopeβœ…fn meow() { const {} }
Argument qualifiers❌fn meow(cat: impl const Cat) {}
Data types❌const struct Cat {}
Drop❌impl const Drop for Cat {}
Closures❌const Η€Η€ {}
Iterators❌for cat in cats {}

Refinements

There are currently no refiments to the compile-time execution effect.

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

References

Fallibility (Scoped Effect)

Feature Status

todo

Description

todo

Refinements

ModifierDescription
Option<T>Used to describe optional values
Result<T, E>Used to describe errors or success values
ControlFlow<B, C>Used to represent control-flow loops
Poll<T>Used to describe the state of Future state machines

While the reification of the fallibility effect in bounds ought to be impl Try, it more commonly is the case that we see concrete types used.

Feature categorization

PositionSyntax
Effecttry
Yieldthrow
Apply?
Consumematch / fn main() †
Reificationimpl Try

† fn main implements effect polymorphism over the fallibility effect by making use of the Termination trait. It stands to reason that if we had a try notation for functions, that it should be possible to write try fn main which desugars to a Result type being returned.

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

May Panic

Memory-Unsafety

Must-not Move

Object-Safety

Ownership

Thread-Safety

Iteration (Scoped Effect)

Feature Status

The Iterator trait has been stable in Rust since 1.0, but the generator syntax is currently unstable. This document will assume that generators are created with the gen keyword, but that's for illustrative purposes only.

Description

todo

Technical Overview

PositionSyntax
Effectgen
Yieldyield
ApplyN/A
Consumefor..in
Reificationimpl Iterator

Refinements

ModifierDescription
stepHas a notion of successor and predecessor operations.
trusted len †Reports an accurate length using size_hint.
trusted stepUpholds all invariants of Step.
double-endedIs able to yield elements from both ends.
exact size †Knows its exact length.
fusedAlways continues to yield None when exhausted.

† The difference between TrustedLen and ExactSizeIterator is that TrustedLen is marked as unsafe to implement while ExactSizeIterator is marked as safe to implement. This means that if TrustedLen is implemented, you can rely on it for safety purposes, while with ExactSizeIterator you cannot.

Positions Available

PositionAvailableExample
Manual trait implβœ…impl Iterator for Cat {}
Free functions❌gen fn meow() {}
Inherent functions❌impl Cat { gen fn meow() {} }
Trait methods❌trait Cat { gen fn meow() {} }
Trait declarations❌gen trait Cat {}
Block scope❌N/A
Argument qualifiers❌fn meow(cat: impl gen Cat) {}
Drop❌impl gen Drop for Cat {}
Closures❌gen Η€Η€ {}
Iterators❌for cat in cats {}

Interactions with other effects

Asynchrony

OverviewDescription
Compositioniterator of futures
DescriptionCreates an iterator of futures. The future takes the iterator by &mut self, so only a single future may be executed concurrently
ExampleAsyncIterator
Implementable as of Rust 1.70?No, async functions in traits are unstable

Fallibility

OverviewDescription
Compositioniterator of tryables
DescriptionCreates an iterator of tryables, typically an iterator of Result
ExampleFallibleIterator
Implementable as of Rust 1.70?No, try in traits is not available

Compile-time Execution

OverviewDescription
Compositionconst iterator
DescriptionCreates an iterator which can be iterated over at compile-time
ExampleN/A
Implementable as of Rust 1.70?No, const traits are unstable

Thread-Safety

OverviewDescription
Compositioniterator of tryables
DescriptionCreates an iterator whose items which can be sent across threads
Examplewhere I: Iterator<Item = T>, T: Send
Implementable as of Rust 1.70?Yes, as a bound on use. And by unit-testing the Send auto-trait on decls.

Immovability

OverviewDescription
Compositionan iterator which takes self: Pin<&mut Self>
DescriptionAn iterator which itself holds onto self-referential data
ExampleN/A
Implementable as of Rust 1.70?Yes

Unwinding

OverviewDescription
Compositioniterator may panic instead of yield
DescriptionCreates an iterator which may panic
ExampleIterator (may panic by default)
Implementable as of Rust 1.70?Yes, but cannot opt-out of "may panic" semantics

Unwinding (Scoped Effect)

Feature Status

todo

Description

todo

Refinements

ModifierDescription

The panic effect currently has no refinements.

Feature categorization

PositionSyntax
EffectN/A
Yieldpanic!
Applyfoo() / resume_unwind
Consumecatch_unwind / fn main
ReificationN/A

Panics differ from all other control-flow oriented effects because every function is assumed to potentially panic. This means that the syntax to forward a panic from a function is just a regular function call. Panics are not represented in the type system, instead they exist as a property outside of it.

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

Memory Safety (Scoped Effect)

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

References

Immovability (Data-Type Effect)

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

Object Safety (Data-Type Effect)

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

Ownership (Data-Type Effect)

Description

Feature Status

Feature categorization

PositionSyntax
Effect
Yield
Apply
Consume
Reification

Positions Available

PositionAvailableExample
Manual trait implβœ…impl Future for Cat {}
Free functionsβœ…async fn meow() {}
Inherent functionsβœ…impl Cat { async fn meow() {} }
Trait methods⏳trait Cat { async fn meow() {} }
Trait declarations❌async trait Cat {}
Block scopeβœ…fn meow() { async {} }
Argument qualifiers❌fn meow(cat: impl async Cat) {}
Data types β€ βŒasync struct Cat {}
Drop β€ βŒimpl async Drop for Cat {}
Closures❌async Η€Η€ {}
Iterators❌for await cat in cats {}

Refinements

ModifierDescription

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

References

Thread-Safety (Data-Type Effect)

Interactions with other effects

Asynchrony

Compile-time Execution

Fallibility

Iteration

Unwinding

Memory-Safety

Immovability

Object-Safety

Ownership

Thread-Safety

Implications of the effect hierarchy

One implication of the subset-superset relationship is that code which is generic over effects will not be able to use all functionality of the superset in the subset case. Though it will need to use the syntax of the superset.

Take for examle the following code. It takes two async closures, awaits them, and sums them:


#![allow(unused)]
fn main() {
// Sum the output of two async functions:
~async fn sum<T>(
    lhs: impl ~async FnMut() -> T,
    rhs: impl ~async FnMut() -> T
) -> T {
   let lhs = lhs().await; 
   let rhs = rhs().await; 
   lhs + rhs
}
}

One of the benefits of async execution is that we gain ad-hoc concurrency, so we might be tempted to perform the comptutation of lhs and rhs concurrently, and summing the output once both have completed. However this should not be possible solely using effect polymorphism since the generated code needs to work in both async and non-async contexts.


#![allow(unused)]
fn main() {
// Sum the output of two async functions:
~async fn sum<T>(
    lhs: impl ~async FnMut() -> T,
    rhs: impl ~async FnMut() -> T
) -> T {
   let (lhs, rhs) = (lhs(), rhs()).join().await;
   //                             ^^^^^^^
   // error: cannot call an `async fn` from a `~async` context
   // hint: instead of calling `join` await the items sequentially
   //       or consider writing an overload instead
   lhs + rhs
}
}

And this is not unique to async: in maybe-const contexts we cannot call functions from the super-context ("base Rust") since those cannot work during const execution. This leads to the following implication: Conditional effect implementations require the syntactic annotations of the super-context, but cannot call functions which exclusively work in the super-context.

Grouping Keyword Generics

We expect it to be common that if a function takes generics and has conditional keywords on those, it will want to be conditional over all keywords on those generics. So in order to not have people repeat params over and over, we should provide shorthand syntax.

Here is the "base" variant we're changing:


#![allow(unused)]
fn main() {
// without any effects
fn find<I>(
    iter: impl Iterator<Item = I>,
    closure: impl FnMut(&I) -> bool,
) -> Option<I> {
    ...    
}
}

We could imagine wanting a fallible variant of this which can short-circuit based on whether an Error is returned or not. We could imagine the "base" version using a TryTrait notation, and the "effect" version using the throws keyword. Both variants would look something like this:


#![allow(unused)]
fn main() {
// fallible without effect notation
fn try_find<I, E>(
    iter: impl TryIterator<Item = I, E>,
    closure: impl TryFnMut<(&I), E> -> bool,
) -> Result<Option<I>, E> {
    ...
}

// fallible with effect notation
fn try_find<I, E>(
    iter: impl Iterator<Item = I> ~yeets E,
    closure: impl FnMut(&I) ~yeets E -> bool,
) -> Option<I> ~yeets E {
    ...
}
}

For async we could do something similar. The "base" version would use AsyncTrait variants. And the "effect" variant would use the async keyword:


#![allow(unused)]
fn main() {
// async without effect notation
fn async_find<I>(
    iter: impl AsyncIterator<Item = I>,
    closure: impl AsyncFnMut(&I) -> bool,
) -> impl Future<Output = Option<I>> {
    ...
}

// async with effect notation
~async fn async_find<I>(
    iter: impl ~async Iterator<Item = I>,
    closure: impl ~async FnMut(&I) -> bool,
) -> Option<I> {
    ...
}
}

Both the "fallible" and "async" variants mirror each other closely. And it's easy to imagine we'd want to be conditional over both. However, if neither the "base" or the "effect" variants are particularly pleasant.


#![allow(unused)]
fn main() {
// async + fallible without effect notation
fn try_async_find<I, E>(
    iter: impl TryAsyncIterator<Item = Result<I, E>>,
    closure: impl TryAsyncFnMut<(&I), E> -> bool,
) -> impl Future<Output = Option<Result<I, E>>> {
    ...
}

// async + fallible with effect notation
~async fn try async_find<I, E>(
    iter: impl ~async Iterator<Item = I> ~yeets E,
    closure: impl ~async FnMut(&I) ~yeets E -> bool,
) -> Option<I> ~yeets E {
    ...
}
}

The "base" variant is entirely unworkable since it introduces a combinatorial explosion of effects ("fallible" and "async" are only two examples of effects). The "effect" variant is a little better because it composes, but even with just two effects it looks utterly overwhelming. Can you imagine what it would look like with three or four? Yikes.

So what if we could instead treat effects as an actual generic parameter? As we discussed earlier, in order to lower effects we already need a new type of generic at the MIR layer. But what if we exposed that type of generics as user syntax too? We could imagine it to look something like this:


#![allow(unused)]
fn main() {
// conditional
fn any_find<I, effect F>(
    iter: impl F * Iterator<Item = I>,
    closure: impl F * FnMut(&I) -> bool,
) -> F * Option<I> {
    ...    
}
}

There are legitimate questions here though. Effects which provide a superset of base Rust may change the way we write Rust. The clearest example of this is async: would having an effect F require that we when we invoke our closure that we suffix it with .await? What about a try effect, would that require that we suffix it with a ? operator? The effects passed to the function might need to change the way we author the function body 1.

1

One interesting thing to keep in mind is that the total set of effects is strictly bounded. None of these mechanisms would be exposed to end-users to define their own effects, but only used by the Rust language. This means we can know which effects are part of the set. And any change in calling signature (e.g. adding .await or ?, etc.) can be part of a Rust edition.

Another question is about bounds. Declaring an effect F is maximally inclusive: it would capture all effects. Should we be able to place restrictions on this effect, and if so what should that look like?

Adding new keyword generics


#![allow(unused)]
fn main() {
const fn foo() { // maybe const context
    let file = fs::open("hello").unwrap();
    // compile error! => `fs::open` is not a maybe const function!
}

~base fn foo() { // assume `const` as the default; invert the relationship
    let file = fs::open("hello").unwrap();
    // compile error! => `fs::open` is
    // a base function which cannot be
    // called from a maybe base context
}

~async fn foo() {
    let file = my_definitely_async_fn().await;
    // compile error!
}
}
fn foo<effect F: const>(f: impl F * Fn() -> ()) {
    f();
}
fn foo<effect F: const>(f: impl effect<F> Fn() -> ()) {
    f();
}

// compile error!
// effect `F` is maximally inclusive!
// missing `.await`

// maximally inclusive effects are not forward compatible! - once
// we add a new effect existing code will not compile!
// The calling convention may change each time we add a new effect!

fn main() {
    foo(some_fn); // Infer all effects to Not*
}

Adding new effects to the language does not break anyone, because effects must be opted in. Adding a new effect to the opt-in effect generics of a function will break callers that infer the effect to be required.

Editions can add new effects to the list of defaults. This is not a breaking change because calling crates can stay on old editions, even if the lib crate got updated to a newer edition. THe lower edition crates don't see the defaults and turn them off.

MIR desugaring

Recently I (Oli) have proposed to add a magic generic parameter on all const fn foo, impl const Foo for Bar and const trait Foo declarations. This generic parameter (called constness henceforth) is forwarded automatically to all items used within the body of a const fn. The following code blocks demonstrates the way I envision this magic generic parameter to be created (TLDR: similar to desugarings).

Examples

Trait declarations


#![allow(unused)]
fn main() {
const trait Foo {}
}

becomes


#![allow(unused)]
fn main() {
trait Foo<constness C> {}
}

Generic parameters


#![allow(unused)]
fn main() {
const fn foo<T: Bar + ~const Foo>() {}
}

becomes


#![allow(unused)]
fn main() {
fn foo<constness C, T: Bar + Foo<C>>() {}
}

Function bodies


#![allow(unused)]
fn main() {
const fn foo() {
    bar()
}
}

becomes


#![allow(unused)]
fn main() {
fn foo<constness C>() {
    bar::<C>()
}
}

Call sites outside of const contexts

fn main() {
    some_const_fn();
}

becomes

fn main() {
    some_const_fn::<constness::NotConst>();
}

Call sites in const contexts


#![allow(unused)]
fn main() {
const MOO: () = {
    some_const_fn();
}
}

becomes


#![allow(unused)]
fn main() {
const MOO: () = {
    some_const_fn::<constness::ConstRequired>();
}
}

Implementation side:

We add a fourth kind of generic parameter: constness. All const trait Foo implicitly get that parameter. In rustc we remove the constness field from TraitPredicate and instead rely on generic parameter substitutions to replace constness parameters. For now such a generic parameter can either be Constness::Required, Constness::Not or Constness::Param, where only the latter is replaced during substitutions, the other two variants are fixed. Making this work as generic parameter substitution should allow us to re-use all the existing logic for such substitutions instead of rolling them again. I am aware of a significant amount of hand-waving happening here, most notably around where the substitutions are coming from, but I'm hoping we can hash that out in an explorative implementation

Overloading Keyword Generics

In the previous section we saw that we cannot use join to await two futures concurrently because in "base Rust" we cannot run two closures concurrently. The capabilities introduced by the superset (async) have no counterpart in the subset ("base Rust"), and therefore we cannot write it.

But sometimes we do want to be able to specialize implementations for a specific context, making use of the capabilities they provide. In order to do this we need to be able to declare two different code paths, and we propose effect overloading as the mechanism to do that.

This problem is not limited to async Rust either; const implementations may want to swap to platform-specific intrinsics at runtime, but keep using portable instructions during CTFE. This is only a difference in implementation, and should not require users to switch between APIs.

The way we envision effect overloading to work would be similar to specialization. A base implementation would be declared, with an overload in the same scope using the same signature except for the effects. The compiler would pick up on that, and make it work as if the type was written in a polymorphic fashion. Taking our earlier example, we could imagine the sum function could then be written like this:


#![allow(unused)]
fn main() {
// Sum the output of two functions:
default fn sum<T>(
    lhs: impl FnMut() -> T,
    rhs: impl FnMut() -> T
) -> T {
   lhs() + rhs()
}

async fn sum<T>(
    lhs: impl async FnMut() -> T,
    rhs: impl async FnMut() -> T
) -> T {
   let (lhs, rhs) = (lhs(), rhs()).join().await;
   lhs + rhs
}
}

We expect effect overloading to not only be useful for performance: we suspect it may also be required when defining the core (async) IO types in the stdlib (e.g. TcpStream, File). These types carry extra fields which their base counterparts do not. And operations such as reading and writing to them cannot be written in a polymorphic fashion.

While we expect a majority of ecosystem and stdlib code to be written using effect polymorphism, there is a point at which implementations do need to be specialized, and for that we need effect overloading.

Prior Art

C++: noexcept(noexcept(…))

C++'s noexcept(noexcept(…)) pattern is used to declare something as noexcept if the evaluated pattern is also noexcept. This makes noexcept conditional on the pattern provided.

This is most commonly used in generic templates to mark the output type as noexcept if all of the input types are noexcept as well.

C++: implicits and constexpr

constexpr can be applied based on a condition. The following example works:

C++ 11

template <
class U = T,
detail::enable_if_t<std::is_convertible<U &&, T>::value> * = nullptr,
detail::enable_forward_value<T, U> * = nullptr>
constexpr optional(U &&u) : base(in_place, std::forward<U>(u)) {}

template <
class U = T,
detail::enable_if_t<!std::is_convertible<U &&, T>::value> * = nullptr,
detail::enable_forward_value<T, U> * = nullptr>
constexpr explicit optional(U &&u) : base(in_place, std::forward<U>(u)) {}

C++ 20

template <
class U = T,
detail::enable_forward_value<T, U> * = nullptr>
explicit(std::is_convertible<U &&, T>::value) constexpr optional(U &&u) : base(in_place, std::forward<U>(u)) {}

todo: validate what this does exactly by someone who can actually read C++.

Rust: maybe-async crate

Rust: fn main

Rust provides overloads for async fn main through the Termination trait. The main function can optionally be made fallible by defining -> Result<()> as the return type. In the ecosystem it's common to extend fn main with async capabilities by annotating it with an attribute. And this mechanism has been shown to work in the compiler as well by implementing Termination for F: Future.

The mechanism of overloading for fn main differs from what we're proposing, but the outcomes are functionally the same: greater flexibility in which function modifiers are accepted, and less need to duplicate / wrap code.

Zig: async functions

Zig infers whether a function is async, and allows async/await on non-async functions, which means that Zig libraries are agnostic of blocking vs async I/O. Zig avoids function colors.

β€” Zig contributors, β€œZig In-Depth Overview: Concurrency via Async Functions,” October 1, 2019

Swift: async overloading

// Existing synchronous API
func doSomethingElse() { ... }

// New and enhanced asynchronous API
func doSomethingElse() async { ... }
  • https://github.com/apple/swift-evolution/pull/1392