keyword generics initiative
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.
Stage | State | Artifact(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
- We recomend basing this on the update template
- 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.
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
.
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).
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.
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.
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.
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:
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 async | async | |
---|---|---|
infallible | fn map | fn async_map |
fallible | fn try_map | fn 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.
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:
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 anis_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 andstruct ?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.
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 .await
s 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
, andyield
.
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 set | can access | cannot access |
---|---|---|
std rust | non-termination, unwinding, non-determinism, statics, runtime heap, host APIs | N/A |
alloc | non-termination, unwinding, non-determinism, globals, runtime heap | host APIs |
core | non-termination, unwinding, non-determinism, globals | runtime heap, host APIs |
const | non-termination, unwinding | non-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 aFile
. - 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" inasync
contexts is possible.
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
Role | Github |
---|---|
Owner | Yosh Wuyts |
Owner | Oli Scherer |
Liaison | Niko 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
- Name:
const bool-like effects
- Proposed by: @Lili Zoey
- Original proposal: comment
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; }
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 effectE
"effect E = false
means "does not have the effectE
"effect E = default
means "has the effectE
if the default for the effect is true"effect E = A
whereA
is a generic variable, means "has the effectE
ifA
is true"effect E = B1 + B2
means "has the effectE
if the boundsB1
andB2
are true"effect E = B1 | B2
means "has the effectE
if the boundsB1
orB2
are true"effect E = !B
means "has the effectE
if the boundB
is false"effect for<effect> = B
means "the effect boundB
applies to every effect"effect A: E
means "A
is a generic variable corresponding to the effectE
"
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.
- Name:
postfix-question-mark
- Proposed by: @tvallotton
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:
- 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.
- 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 logicalnever
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 usemaybe
patterns should always be able to call functions which takealways
patterns - runtime assertions usingmatch
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 ausize
, but would want to take ausize is 1..
. The same is true for the unstableIterator::array_chunks
andIterator::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 onveggies
andmeat
. - concurrency:
veggies
,meat
, andoven
are computed concurrently - constraint:
Meal
depends onoven
anddish
- concurrency:
oven
anddish
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:
- 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. - 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 .await
ed - 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:
- We define a new unsafe auto-trait named
Leak
- All bounds take an implicit + Leak bound, like we do for
+ Sized
. - Certain functions such as
mem::forget
will always keep taking+ Leak
bounds. - Functions which want to opt-in to linearity can take
+ ?Leak
bounds. - Types which want to opt-in to linearity can implement
!Leak
or put aPhantomLeak
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 byeffect 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
- The pain of Real Linear Types in Rust
- Must move types
- Linearity and Control
- Linear Types One-Pager
- Async destructors, async genericity and completion futures
- Changing the Rules of Rust
- Follow up to "Changing the rules of Rust"
- Generic trait methods and new auto traits
π 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.
- Feature Name:
effect-generic-trait-decls
- Start Date: (2024-01-01)
- RFC PR: rust-lang/rfcs#0000
- Rust Issue: rust-lang/rust#0000
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.
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:
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 beasync
. - 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 placeholdermaybe(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 sigilif/else
at the trait level does not create bidirectional relationshipsasync<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
andstd
via sets
TODO: Normalize const
const fn
is maybe-constconst {}
is always const- this is super annoying lol, and that's why this system doesn't work for
const
right now
- Feature Name:
effect-generic-bounds-and-functions
- Start Date: (2024-01-20)
- RFC PR: rust-lang/rfcs#0000
- Rust Issue: rust-lang/rust#0000
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 .await
s 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
.await
ed - 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 name | forwarding notation | desugaring | logical return type | carried type |
---|---|---|---|---|
async | .await | impl Future<Output = T> | T | ! |
try β | ? | impl Try<Output = T, Residual = R> β | T | R |
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 effect | caller may have an effect | caller 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 callingFile::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;
- preferred notation:
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 asimpl 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 areSend
, 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
Position | Syntax |
---|---|
Effect | async |
Yield | N/A |
Apply | .await |
Consume | thread::block_on β , async fn main β‘ |
Reification | impl 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 theWake
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 existingfn main
logic in athread::block_on
call.
Positions Available
Position | Available | Example |
---|---|---|
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
Modifier | Description |
---|---|
cancellation-safe | Has 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.
declaration | usage | |
---|---|---|
keyword never applies | fn meow() {} | fn hello() { meow() } |
keyword always applies | - | const CAT: () = {}; |
keyword conditionally applies | const 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
Position | Syntax |
---|---|
Effect | const fn |
Yield | N/A |
Apply | automatic |
Consume | const {} , const X: Ty = {} |
Reification | N/A |
Positions Available
Position | Available | Example |
---|---|---|
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
Modifier | Description |
---|---|
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
Position | Syntax |
---|---|
Effect | try |
Yield | throw |
Apply | ? |
Consume | match / fn main() β |
Reification | impl Try |
β
fn main
implements effect polymorphism over the fallibility effect by making use of theTermination
trait. It stands to reason that if we had atry
notation for functions, that it should be possible to writetry fn main
which desugars to aResult
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
Position | Syntax |
---|---|
Effect | gen |
Yield | yield |
Apply | N/A |
Consume | for..in |
Reification | impl Iterator |
Refinements
Modifier | Description |
---|---|
step | Has a notion of successor and predecessor operations. |
trusted len β | Reports an accurate length using size_hint . |
trusted step | Upholds all invariants of Step . |
double-ended | Is able to yield elements from both ends. |
exact size β | Knows its exact length. |
fused | Always continues to yield None when exhausted. |
β The difference between
TrustedLen
andExactSizeIterator
is thatTrustedLen
is marked asunsafe
to implement whileExactSizeIterator
is marked as safe to implement. This means that ifTrustedLen
is implemented, you can rely on it for safety purposes, while withExactSizeIterator
you cannot.
Positions Available
Position | Available | Example |
---|---|---|
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
Overview | Description |
---|---|
Composition | iterator of futures |
Description | Creates an iterator of futures. The future takes the iterator by &mut self , so only a single future may be executed concurrently |
Example | AsyncIterator |
Implementable as of Rust 1.70? | No, async functions in traits are unstable |
Fallibility
Overview | Description |
---|---|
Composition | iterator of tryables |
Description | Creates an iterator of tryables, typically an iterator of Result |
Example | FallibleIterator |
Implementable as of Rust 1.70? | No, try in traits is not available |
Compile-time Execution
Overview | Description |
---|---|
Composition | const iterator |
Description | Creates an iterator which can be iterated over at compile-time |
Example | N/A |
Implementable as of Rust 1.70? | No, const traits are unstable |
Thread-Safety
Overview | Description |
---|---|
Composition | iterator of tryables |
Description | Creates an iterator whose items which can be sent across threads |
Example | where 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
Overview | Description |
---|---|
Composition | an iterator which takes self: Pin<&mut Self> |
Description | An iterator which itself holds onto self-referential data |
Example | N/A |
Implementable as of Rust 1.70? | Yes |
Unwinding
Overview | Description |
---|---|
Composition | iterator may panic instead of yield |
Description | Creates an iterator which may panic |
Example | Iterator (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
Modifier | Description |
---|
The panic
effect currently has no refinements.
Feature categorization
Position | Syntax |
---|---|
Effect | N/A |
Yield | panic! |
Apply | foo() / resume_unwind |
Consume | catch_unwind / fn main |
Reification | N/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
Position | Syntax |
---|---|
Effect | |
Yield | |
Apply | |
Consume | |
Reification |
Positions Available
Position | Available | Example |
---|---|---|
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
Modifier | Description |
---|
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.
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