Rethinking Rust's Function Declaration Syntax
We had a fun discussion in #t-lang about possible new syntax for declaring functions in Rust. There were a lot of cool ideas put forward, and while mulling them over I realized a lot of them work nicely together and can be introduced in a backwards-compatible way to give us some cool new capabilities. While these were fresh in my mind and I'm feeling excited about them, I wanted to write them in one place.
For background, top level functions in Rust look sort of like this:
fn foo(x: i32) -> i32 { x + 1 }
In Rust 2018, we added async fn
:
async fn foo(x: i32) -> i32 { x + 1 }
While that one doesn't do anything particularly interesting, an async function gives you the ability to use await
inside it.
It also secretly changes the return type from an i32
to an impl Future<Output = i32>
.
This is regarded by many to have been a mistake, and it's starting to cause issues now that we have async functions in traits since there is no way to add additional bounds like Send
to the return type.
Anyway, async fn foo
is mostly just syntactic sugar that desugars into:
fn foo(x: i32) -> impl Future<Output = i32> { async { x + 1 } }
It's likely that Rust will gain a whole bunch of new keywords we can stick in front of fn
in the future.1
For example, nightly Rust just got support for gen fn
and async gen fn
.
Those desugar similar, by wrapping the return type in impl Iterator
or impl AsyncIterator
and wrapping the body in gen { }
or async gen { }
.
Another piece of sugar we could add is try fn
, which is actually what started off the discussion thread today.
Following the pattern we've had so far, we'd expect to be able to write something like:
try fn foo() -> i32 { let x = read_number()?; x }
and have this desugar to:
fn foo() -> impl Try<Output = i32, Residual = ???> { try { let x = read_number()?; x } }
The problem is we need a hint for the Residual
type.
The obvious thing to do would be to add something to the function header, like try fn foo() -> i32 throws E
.
But if you've ever looked at the Residual
types for the Try
impls in the standard library, you know that these can look pretty hairy and not particularly intuitive.
For example, to make a function that returns an Option
, we'd need to write:
try fn foo() -> i32 throws Option<Infallible> { let x = read_number()?; x }
This would give the compiler enough information to find the Try
impl for Option
.
But notice that we also could have just written fn foo() -> Option<i32>
, which is shorter and you don't have to figure out why my fallible function has an Infallible
in it.
At this point, Lukas Wirth observed that they would rather see a shorthand for functions whose body is a single expression.
If we did this, we could write try fn
as:
fn foo() -> Option<i32> = try { let x = read_number()?; x }
So that's pretty neat.
This also invites us to reconsider async fn
.
We could instead write:
fn foo() -> impl Future<Output = i32> = async { let x = read_number().await; x }
That's not too bad, but impl Future<Output = i32>
is a bit wordy.
We could come up with some rules that would let you write impl Future<i32>
instead, which honestly is how we usually read that out loud anyway.
But then joboet and pitaj pointed out that we could treat Trait -> Type
as shorthand for Trait<Output = Type>
.
TC pointed out that we could probably generalize this to support yields T
and Iterator<Item = T>
.
So if we combined a few of these ideas, we'd be able to write:
fn foo() -> impl Future -> i32 = async { let x = read_number().await; x }
I think this shows a lot of potential.
I want to try to generalize this a bit more though.
Instead of special-casing the Output
associated type, we could create a set of attributes indicate an associated type can be used with trait keyword shorthands.
For example, define the Future
and Iterator
traits like this:
trait Future { #[keyword(return)] type Output; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>; } trait Iterator { #[keyword(yields)] type Item; fn next(&mut self) -> Option<Self::Item>; }
This would let us refer to Future<Output = T>
as Future -> T
and Iterator<Item = T>
as Iterator yields T
.
We could even combine them:
trait Coroutine<R> { #[keyword(yields)] type Yield; #[keyword(return)] type Return; fn resume(self: Pin<&mut Self>, arg: R) -> CoroutineState<Self::Yield, Self::Return>; } fn coroutine() -> impl Coroutine<()> -> bool yields i32 = || { yield 42; true }
This would also let remove some of the special handling around the Fn*
traits and we could expose this functionality to users so libraries could use this sugar in their own traits.
At this point, I'd like to take a step back and think about plain fn
functions.
Notice that the following two would be equivalent:
fn foo() -> i32 { let number = read_number(); number } fn foo() -> i32 = { let number = read_number(); number }
One way of think of this is that we've made the =
optional.
But I'd like to think of it a different way.
Let's say instead we think of the =
form as the standard function declaration syntax.
Then, if the function body consists of a single block, we can use a compressed syntax.
For a regular { }
block, that just looks like the function declaration syntax we're used to.
But for blocks with a keyword in front, like async { }
or try { }
, we say the keyword moves all the way to the front of the function header.
In addition, each block as an characteristic trait associated with it, so when we used the block shorthand for function declarations, we also wrap an impl Trait
around the return type.
Here are some examples:
// async //////////////////////////////////////// async fn foo() -> i32 { let number = read_number().await; number } // desugars to: fn foo() = impl Future<Output = i32> = async { let number = read_number().await; number } // gen ////////////////////////////////////////// gen fn foo() -> i32 { yield 1; yield 2; } // desugars to: fn foo() = impl Iterator<Item = i32> = gen { yield 1; yield 2; } // assuming `Iterator` is defined like: trait Iterator { #[keyword(return)] type Item; fn next(&mut self) -> Option<Self::Item>; } // async gen //////////////////////////////////// async gen fn foo() -> i32 { yield 1; yield 2; } // desugars to: fn foo() = impl AsyncIterator<Item = i32> = async gen { yield 1; yield 2; }
I've left try out because it's complicated. You could technically do something like:
try fn foo() -> i32 throws Option<Infallible> { let number = read_number()?; number }
but for try
users usually want to know the concrete type.
So instead I'd expect most people to prefer the desugared form:
fn foo() -> Option<i32> = try { let number = read_number()?; number }
Note that the "pulling the keyword forward" transformation doesn't work because this function returns a concrete type and what I've proposed here is that pulling the keyword forward always adds an impl Trait
rather than a concrete type.
Anyway, I'm pretty excited about this idea.2 It feels like a consistent way to handle these connections between blocks, traits, and functions. It's backwards compatible with the syntax we have so far, but it gives us a lot more expressiveness in cases where we're currently missing it.
You can already do unsafe fn
and const fn
today, but these don't desugar in the same way as other proposed keywords here do.↩
Of course, I also just started thinking about this today and cranked out a blog post, so I may hate it by Monday.↩