Pre-Pre-RFC: async methods & bounding async fns

o_O? Wouldn’t that do the async fn definition magic and again wrap the result, giving something like:

fn foo() -> impl Future<Output = impl Future<Output = T> + 'in> + 'in { ... }

Edit: (fixed quote)

@lordan The RFC just chose to use the inner return type approach. The code above uses the outer return type approach (no wrapping). See my comment above about the advantages. IMO the RFC did not properly consider the downsides this thread has discovered with the inner return type approach. Therefore a reevaluation is required.

Abstract types are also no problem with the outer return type approach:

// Note: Outer return type approach!

abstract type Foo<'a>: Future<Output=i32> + 'a;

async fn foo(a: &i32, b: &i32) -> Foo<'in> { ... }
3 Likes

Thanks for the clarification, I missed that part!

Agreed, that does look cleaner to me as well. Less magic is always a plus.

1 Like

If we write the Future itself I don’t see the need for async functions anymore. const and unsafe both have effects other users should care about when they see the api in the documentation. However

async fn foo(a: &i32, b: &i32) -> impl Future<Output = i32> + Send + 'in {
    //...
}
// would be equivalent to
fn foo(a: &i32, b: &i32) -> impl Future<Output = i32> + Send + 'in {
    async {
        //...
    }
}
// or if you dont want another level of indentation.
fn foo(a: &i32, b: &i32) -> impl Future<Output = i32> + Send + 'in { async {
    //...
}}

This way using async would stay an implementation detail. Another benefit of this approach would be the simularity to more complex functions like

fn foo(a: &i32, b: &i32) -> impl Future<Output = i32> + Send + 'in {
    // do some work first
    let future = async {
        //...
    }
    // and maybe even use some combinators.
    future
}

This approach has no need for additional syntax like async(Send). Maybe async fn can be added as syntactic sugar later but I don’t think it should be included in the initial proposal.

1 Like

Yes, but:

  • It’s the most frequent use case. The async fn syntax isn’t strictly necessary, but it improves ergonomics. Its frequent use justifies its existence.

  • async would not be the first thing that rustdoc chooses not to show: E.g

    // Today: mut
    pub fn foo(mut a: i32) { ... } // Code
    pub fn foo(a: i32) // Rustdoc
    
    // async (outer return type approach)
    pub async fn foo (a: &str, b: &str) -> imp Future<Output=i32> + 'in { ... } // Code
    pub fn foo (a: &str, b: &str) -> imp Future<Output=i32> + 'in // Rustdoc
    
  • }} is very ugly

  • Other programming languages have async functions as well.

1 Like

outputof would accept a path, which must be a path to a function. Another piece of syntax that takes a path, not an expression or a type, is a use statement.

1 Like

Starting a blog series about this. The first one is just an explanation of why async methods require GATs:

7 Likes

Length comparison:

  1. Inner return type (approach from RFC)
  2. Outer return type with implicit 'in lifetime
  3. Outer return type
// Declaration
async fn foo(a: &str, b: &str) -> i32; // (1)
async fn foo(a: &str, b: &str) -> impl Future<Output = i32>; // (2a)
fn foo(a: &str, b: &str) -> impl Future<Output = i32> + 'in; // (2b)
fn foo(a: &str, b: &str) -> impl Future<Output = i32> + 'in; // (3)

// Definition
async fn foo(a: &str, b: &str) -> i32 { ... } // (1)
async fn foo(a: &str, b: &str) -> impl Future<Output = i32> { ... } // (2)
async fn foo(a: &str, b: &str) -> impl Future<Output = i32> + 'in { ... } // (3)
  • Declaration 2a uses the async keyword to add the 'in lifetime
  • Declarations 2b & 3 do not use the async keyword. There’s no need for it in declarations
  • 3 is free from any signature transformation. This means that the signature minus the async keyword behaves exactly like every other function signature in Rust. No strings attached.
1 Like

It would be a breaking change to make every function a type name, because you can already have functions & types with the same name in the same scope (the compiler does this with the P type, for example). So its not foo::Output, we’d need a typeof mechanism to do <typeof foo>::Output.

Wouldn’t it be possible to consider the type of the function being shadowed by the other type? I think there are not too many cases where this actually happens since functions are usually in snake_case while structs and enums are in CamelCase. And in the cases where such a shaddowing occurs we could just lint that. <typeof foo>::Output could be an alternative explicit syntax, to bypass that shaddowing.

The main reason why I don’t like the Idea of that typof syntax is that it might get very noisy when refering to specific functions of a Trait.

trait Foo {
  fn fun(&self) -> impl Bar;
}

struct FooImpl;
impl Foo for FooImpl { /* ... */ }

fn takes_foo(foo: &FooImpl) -> <FooImpl as Foo>::<typeof fun>::Output {  /* ... */ }
fn takes_foo(foo: &FooImpl) -> <FooImpl as Foo>::fun::Output {  /* ... */ }

/* or even more desriable */
fn takes_foo(foo: &FooImpl) -> FooImpl::Foo::fun::Output {  /* ... */ }

1 Like

I believe under the most likely interpretation of typeof this would be

fn takes_foo(foo: &FooImpl) -> <typeof <FooImpl as Foo>::fun>::Output {  /* ... */ }

<FooImpl as Foo>::fun already refers to the value of the function foo defined on impl Foo for FooImpl. You then need the type of that function value to refer to its associated output type.

1 Like

@Nemo157 I imagined typeof working that way as well (code example above) because <.. as Trait> works the same way.

What’s interesting about @joeschman’s notation is that it can be read from left to right because it does not rely on nesting. It is reminiscent of how method calls look:

<typeof <FooImpl as Foo>::fun>::Output // Nested

FooImpl::<as Foo>::<typeof fun>::Output // Without nesting

Edit: I should clarify: I mainly like the notion of a nesting-free, left-to-right readable notation, not necessarily that particular notation (although it looks nice at first glance). It’s definitely an interesting new idea :slight_smile:

1 Like

With the the interpretation of typeof as a function from fn to a type that’s true. But that interpretation/implementation would even make the syntax clumsier. Especially when using longer type/function names than Foo / fun parsing the meaning of a signature could get quite exhausting (for a human).

As @MajorBreakfast interpreted correctly is that the point is that I’d definitely prefer a sequential syntax to one with lots of nested angle brackets, since I think it’s more natural to read.

We’ve discovered an important fact.

The “normative” use case of async/await expects that all futures are Send. The default API for executors allows the executor to assume that it can move the future between threads, and so it bounds the future as Future + Send. Exceptions, like embedded programming, don’t use that default API.

What this means is that the vast majority of async fns will have to produce Send futures. For free async functions, this isn’t a problem: every time you call one, we have the concrete type available, and so we can typecheck that it is Send. But for trait methods called on generics, we don’t know. That’s the whole motivation of bounding async fns in the first place.

That is to say, with the original proposal, pretty much every trait would write its async methods async(Send) fn. This seems bad: if 95% of use cases are going to go one way, that way should probably be the default.

But if we decided that async fns return impl Future + Send by default, we need a way to opt out. @aturon proposes that they would just not use the sugar, and instead write the impl Future version:

trait Foo {
    // no Send bound
    fn foo(&self) -> impl Future<Output = i32>  + 'all {
        async {
            // body
        }
    }
}

An alternative would be to support a ?Send bound in this position:

trait Foo {
    async(?Send) fn foo(&self) -> i32 {
        // body
    }
}

@Nemo157 I’m especially interested in hearing your thoughts about this because I think in your embedded use case you’re using a single threaded executor. Are you taking advantage of it with non-Send futures?

5 Likes

@withoutboats Interesting. If we’re going with a Send default which should cover most use cases and an explicit syntax exists for more general needs, then perhaps we can leave the sugar out initially, letting people use the salty version and then see what sugar we really need?

Oh & I forgot that there’s pretty much no need for typeof or outputof if the default is to be Send: there’s no reason to support something like outputof(T::foo): ?Send, its only at the definition site of the trait where you could meaningfully make this change.

So if we go with the Send default, all of the questions around typeof can be dropped out of this discussion and postponed; we just have to decide on the definition site opt out mechanism.

Here’s a list with all the reasons why I think the “inner return type” approach is the wrong choice:

  • Pro: It makes our code a tiny bit shorter
  • Undecided: Learnability - The “outer return type” approach requires an explanation for what impl Trait and the 'all lifetime mean. The “inner return type” approach is shorter but it requires an explanation about the return type and its lifetime as well. Explaining it as part of the signature is not possible.
  • Con: The “inner return type” approach is unlike the rest of the language. Other function signatures are simply what they are. There is AFAIK no precedence for a keyword changing the rest of a signature in such a way that it could be expressed by another signature
  • Con: Incompatibility with abstract types. The abstract types RFC has been merged. It is very likely that we would want to use the feature with async functions after it has been implemented. The “inner return type” approach will prevent us from using it.
  • Con: Choosing Send as default bound is problematic. I always imagined async functions to be a great way to implement cooperative multitasking solutions in embedded programming. { async { … }} is an acceptable workaround, but should we really discuss a workaround for a feature that is not even fully implemented? Do we really want this notational split between asynchronous functions that return a type that is Send and those that don’t? Edit: I moved this into the last point. The Send thing is just a complicated rule.
  • Con: Inability to specify bounds. Specifying bounds cannot work the same way as it does for other functions. An async(Trait) syntax was proposed to allow specifying bounds, but it has downsides: It’s at the front which makes it look like it affects the function (but it affects the return type) and any such notation is inherently inconsistent to how it’s usually done
  • Con: Notational split between async fn and initialization pattern. As a result, newcomers will think the initialization pattern to be an advanced feature. In reality the difference isn’t big at all.
  • Con: We need to remember a set of rules to be able to tell how the signature will look like after the transformation. Especially the rule around Send that only applies for an async method declaration in a trait definition is difficult! We should think twice about introducing indirection that could easily be avoided!

Edit: I revised the “inability to specify bounds” point and the “remember a set of rules” point


I like calling the lifetime 'all instead of 'in. I think both are good name choices.

6 Likes

This is not what it’s about. It’s about the mental model of async-as-an-effect. async fn f() -> T is significantly clearer in that sense than async fn f() -> impl Future<Output = T>, and doubly so in the presence of lifetime parameters, even/especially if we had some magical 'in lifetime.

async is and should be usable without first learning about impl Trait, or anonymous types, or futures; just as sync functions are usable without first learning about the call stack or the function traits. And for that matter, even once you’ve learned those things it shouldn’t be necessary to deal with them in the general case.

In this sense, the inner return type approach is very much like the rest of the language. The function provides a T when its body has completed, not a future. The future is only the mechanism by which this is accomplished, and you only need to care if you need concurrency. (This is the same reasoning I have for explicit async/implicit await.)

Like futures, abstract types are a means to an end. Given another way to name an async fn's associated type, they are unnecessary. Such a mechanism may arguably be better—abstract types, again, involve learning about anonymous types and inference, while directly writing typeof(foo)::Output or even just foo::Output or foo is quite a bit more straightforward.

The outer return type approach does not help here. In standalone async fns, auto traits already “leak,” and any other traits may/must be implemented explicitly by naming the type. In traits, the bound must be part of the trait and not the method, or else implementations will not have to fulfill it.

1 Like

I really disagree with this, as someone without much knowledge of async functions I would think this is an function that does something in the background and returns a T. This is not at all what is happening and I think it makes it less clear when we don’t specify the output of a function directly. This would mean we would have to remember precisely in what way async changes the output insted of just looking behind the -> like with all other functions.

I think impl Trait is quite intuitive even without knowing about anonymous types. It just returns something that implements Trait.

async fn is not the only place an abstract type would be used and I think it would be greatly beneficial to learnability to to have async use the same syntax as other things that do a similar thing instead of having unique syntax that isn’t really used anywhere else.

I think fn foo() -> impl Future<Output = T> + Trait (the async can be left out of the docs) is a lot clearer than async(Trait) foo() -> T. A large factor in this is that the Trait bound is at the normal place for the return type not the start of the function. At the start it looks like something about the function as a whole instead of the return type.

Another big benefit is that other functions that return a future have the same signature.

4 Likes

impl Future<Output = T> for me is a good indicator that you have to do something before you have your T. This is even without knowing what impl means precisely.

Without this I think we would see many questions about why this async function does not return a T like writen in the return of the function. I can see many people trying to use the value as a T only to get an error indicating that is is not a T at all and still having to learn about futures.

2 Likes

And you would be almost entirely correct. Just replace "in the background" with "when polled" or "when awaited" and that's exactly correct!