Introduction
In july of 2022, a new iniciative was presented by Yoshua Wuyts in the Inside Rust Blog. This new iniciative was aiming to make possible the generalization between async
and non-async
traits and functions. Which means that we no longer have to split traits and functions in two to handle the both behaviour contexts.
I am not going to focus on the first released document because a week ago, a second update about the iniciative had been released. This time, including aswell the desire to make generic the const
and non-const
traits and functions.
At the time I am writting this article, Rust 1.69 is the latest nightly version. Take that in count since this iniciative is already focusing in changes that, at this moment, are not even available in standard Rust. Like async
traits.
Async traits in Rust
When it comes to defining async
functions and traits in Rust, the only way of doing it is by using the async_trait crate. If you are curious about why it is not standard to define async traits in Rust, I’d highly recommend to read this article.
If we try to declare a function of a trait as async
, the next error message will be shown when compiling our crate:
error[E0706]: functions in traits cannot be declared `async`
--> src/main.rs:2:5
|
2 | async fn Foo() {
| -----^^^^^^^^^
| |
| `async` because of this
|
= note: `async` trait functions are not currently supported
= note: consider using the `async-trait` crate: https://crates.io/crates/async-trait
= note: see issue #91611 <https://github.com/rust-lang/rust/issues/91611> for more information
= help: add `#![feature(async_fn_in_trait)]` to the crate attributes to enable
But even using the recomended crate for asynchronous functions, we still have the lack of generalization. See the next code snippet that fails to compile:
#[async_trait]
trait MyTrait {
async fn bar(&self);
}
struct FooAsync;
struct FooNotAsync;
#[async_trait]
impl MyTrait for FooAsync {
async fn bar(&self) {}
}
impl MyTrait for FooNotAsync {
fn bar(&self) {}
}
//////////////////////////////////////
--> src/main.rs:17:11
|
5 | async fn bar(&self);
| ----- ---------- lifetimes in impl do not match this method in trait
| |
| this bound might be missing in the impl
...
17 | fn bar(&self) {}
| ^ lifetimes do not match method in trait
Then the only possible way is either by creating two different traits, or by declaring two different functions.
What the KGI is proposing?
The Keyword Generics Iniciative (KGI from now on) is proposing a new syntax in order to declare traits and functions generics over asyncness and constness. The same way we already have generalization over types.
In all the article, I will be focusing only in the asyncness generalization, since it is the same explanation for constness.
This is the proposed syntax at this moment:
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)
}
Yayy!! Finally we have our generalization over asyncness. The syntax for ?async
, is choosen to match other examples like ?Sized
. If we call read_to_string
in an asynchronous context, it will be locked in the .await?
call, waiting to be completed. If we call it in a non-async context, it will just work as a no-op.
// `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?;
}
In addition to that, the KGI is also proposing a new built-in function called is_async()
. This will let the user to create flow branches depending on the calling context.
Advantages
The advantages are pretty obvious, this is super powerful to avoid duplication code. It is almost like magic. Just one function that let the user use it in different contexts.
This will help a lot of crates and even the standard library in order to reduce the existing splitted implementatios for async
and non-async
contexts. It will even let the programmer of libraries to declare all theirs traits as “maybe async” and let the user choose what they want to implement.
Disadvantages
Syntax complexity
The main disadvantage that was brought by the community is the syntax. I also think that the Rust syntax is becoming more and more complex everytime and with one of the main objectives of the Rust Team being the goal of making Rust more easy to learn, this will not help at all.
I told that I didn’t want to include in this article the “maybe const” signature, but it is important to mention that they could be used together, e.g:
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)
}
The KGI is also thinking about a combined syntax for that called ?effect/.do
but I will not enter on that because it is not included in the actual proposal.
So… Hard to understand that code for “just” a “maybe async and maybe const” trait and functions right? Let’s make it even more funny. Lets add them generic types, e.g:
trait ?const ?async Read {
?const ?async fn read<T: Display + Add>(&mut self, buf: &mut ?const ?async T) -> Result<T, Box<dyn Error>>;
}
This is getting out of hands… I would spent half of my daily work just reading over functions signatures… For those curious, there are 111 characters just to declare a function with 1 input parameter.
Low implementation efforts
It is easy to see lot of people using this feature to vagly declaring functions. Something like: “I don’t know if this will be async or not, let’s declare it as maybe”. Causing a lot of half implementations and smelly code that indicates that this is ?async
so lets call with async
and non-async
context.
I think at this point is where the compiler should take action. We should avoid at all cost the possibility of a user calling a function in an async context, just to later realize that the implementation is completaly synchronous. This is not acceptable, the compiler should check if a possible async
and non-async
branch flow are defined, and this could be difficult if we want to make it absolutely opaque from the user by just interpreting .await?
as a no-op in a synchronous context. But it will be easier with the is_async()
and is_const()
proposed.
My proposed changes
Since I would really like to see this running in the future, I would also like to propose some of my thoughts on how we could improve it.
Syntax
I really like the ?async
syntax. I prefer it over the async?
signature that some other users were proposing. The question mark prefix is already being used for traits and I do think that fits well with the maybe async context.
Even though the ?effect/.do
is not formally proposed, I hope that the combination of ?async
and ?const
will not go throgh. This will add so many syntax complexity and Rust is aiming for exactly the opposite at this moment.
Implementation restrictions
It is important to ensure the user will never face a moment when it is calling a function in an asynchronous context and the function is working entirely synchronous. The compiler must check if requierements are satisfied fot both contexts.
Other option is to split the implementations in two functions, e.g:
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> { ... }
}
/// Async function.
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)
}
/// Non-async function.
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)
}
Even though this still produces some duplication code, it also gives the advantage of declaring the trait and functions once and calling the correct function without the user action.