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

If functions are addressable as a type to query their <... as FnOnce>::Output, it would make sense to take a $:ty match.

However, this isn't possible even with $($:tt)+ matches and unboxed_closures in user code:

error[E0575]: expected associated type, found method `MyTrait::foo`
  --> src/main.rs:13:23
   |
13 |     let _ : OutputOf!(<MyStruct<u32> as MyTrait<u32>>::foo)
   |                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ not a associated type

I'd like to note though that if we get <..> elision for generic arguments, OutputOf! can almost be implemented purely in library code:

macro_rules! OutputOf {
    ($($path:tt)+) => (<$($path)+ as FnOnce<..>>::Output);
}

Of course, if it becomes a compiler built-in macro to work around the unstable nature of the Fn* traits, then you could handwave a bit and say it takes a "path to a method" (which seems the compiler already knows about, to say that it isn't an associated type).


Sorry, I didn't see that you mentioned type directly. I'm too tired to reorganize this on my phone but the last paragraph is still directly relevant, it just needs the earlier bits for context.

I've also mixed up what works and what doesn't a bit at this point so I may have missed something obvious, that's why I'm leaning on actually putting things on play.

Assorted notes:

  • I like 'in. Short and sweet as @MajorBreakfast said.

  • We can improve upon <MyFuncTy as FnOnce<(u32,)>>::Output with a type alias like so:

    type Out<F, Args> = <F as FnOnce<Args>>::Output;
    
    Out<MyFuncTy, (u32,)>
    

    Using Out will ensure that the bound FnOnce<Args> is satisfied but it is not checked on the type alias itself right now.

  • I second @withoutboatsā€™s view on -> async T. Some more notesā€¦

  • async is an effect, just like unsafe, const (anti-effect / restriction) and try (assuming we have tryā€¦) are. Whatever we do, I think it is important that there be consistency between effects so that we donā€™t have (more) of an unprincipled mess. That is, if we write:

    fn foo() -> async i32 { .. }
    

    Then you should also write:

    fn foo() -> unsafe i32 { .. }
    fn foo() -> const i32 { .. }
    ...
    

    However, the ship has sailed on unsafe fn.

  • One could imagine the following syntaxes (which Iā€™m not suggestingā€¦)

    fn foo() -> async(T, Bound) { .. }
    fn foo() -> async(T) { .. }
    
  • Some criticisms of async(Bound) fn foo() -> i32:

    1. Bound is too front and center, especially if Bound is long.
    2. It appears before quantification of type variables and where, adding yet another syntax for bounds.
  • With the introduction of 'in, maybe consider the following instead:

    trait Async<T> = Future<Output = T>
    
    async fn foo(x: &Type1, y: &Type2) -> impl 'in + Async<i32> { .. }
    

    or even:

    trait Async<T, trait B = 'static> = Future<Output = T> + B;
                   // ^--- This depends on quantification over bounds.
                   // Can't do that right now; but it can be added later
                   // backwards compatibly. EDIT: Actually, probably not..
    
    async fn foo(x: &Type1, y: &Type2) -> impl Async<Type3, 'in> { .. }
    
    async fn foo(x: u32, y: u32) -> impl Async<u32> { .. }
    
    async fn foo(x: u32, y: u32) -> impl Async<u32, Send> { .. }
    

    This seems to work better in conjunction with proposed schemes for try fn:

    try fn foo() -> Result<T, E> { .. }  // return type is mentioned as above!
    

I prefer <MyFuncTy as FnOnce(u32,)>::Output because that's more direct. However this syntax should only be required in cases where it is necessary to resolve ambiguity, i.e. next to never: For the type of a function there'd be never any ambiguity (because functions can't implement arbitrary traits) and for most other types ambiguities are really rare (we hardly ever need to use this syntax to call methods after all). So the short version MyFuncTy::Output (or MyType::MyAssocType in general) should almost always work.

^^' complicated (too indirect for my taste). But the idea is good. It's essentially specifying the outer return type:

async fn foo(x: &Type1, y: &Type2) -> impl Future<Output=i32> + 'in { .. }

The RFC mentions 3 reasons against specifying the outer return type:

  • Lifetimes: Can be considered solved by 'in. Also, if the inner to outer type conversion like the RFC defines it isn't considered too drastic, a simple shortcut that makes + 'in optional for async functions is certainly also possible!
  • Polymorphic return not required: The rationale is that it's always an impl Future, so there's no need to specify it. While this is somewhat true, we've now discovered serious downsides to this which the RFC does not mention. See next section.
  • Documentation: With point 1 addressed I consider it a rather weak argument

Upsides to specifying the outer return type after all:

  • Bounds can be specified as usual. No weird async(Send) fn required
  • Seamless integration with abstract types
  • It makes async functions more similar to regular functions
  • async then only affects the body, not the signature
    • async (like mut in fn foo(mut a: i32)) can simply be omitted in Rustdoc.
    • This makes the signatures of functions using the initialization pattern and functions defined via async fn identical.

It's a bit late now that the RFC is merged. But nobody had the 'in lifetime idea when we discussed the RFC. Going with the outer return type has advantages and we should talk about it again now that we have these insights.

3 Likes

So the idea was to shorten Future<Output = T> to Async<T> which becomes somewhat more ergonomic if you write it often. However, this trait alias can be added in user code, so you can make it as short as you wish if you repeat it often. If it turns out to be a common problem after years of experimentation, the alias can be added to the standard library.

One additional benefit of -> T instead of -> impl Future<Output = T> is that it mixes well with ideas about async-polymorphism, i.e: if you could write:

?async fn foo() -> T { .. }

Then you can let the caller decide whether the return type is a future, or just a normal T. This could enable us to write a library that is polymorphic over async-ness and let the client application decide. Writing -> impl Future<Output = T> out explicitly takes that option away.

Of course, writing:

?async(Send) fn foo() -> T { .. }

would make no sense.

Sure. However, we're talking about saving 8 characters, so I guess not ^^' Edit: If you want something short: How about a (user defined) fut!(T) macro? Will the proposed type trait aliases even be able to convert between a generic param and an associated type?

Hmm. Reminds me of the mut-polymorphism I've read about discussed somewhere long ago that was ultimately discarded. As a result we now have for example Iter and IterMut. Both are nice ideas, but IMO too ambitious to be practical. Some downsides:

  • It means that we need to specify whether we want to use the sync or the async version in some cases. Type inference will sometimes not be able to tell
  • my_fn::Output is impossible. At least not if there's no way to specify whether we want the sync or the async version.
  • A lot of async code requires a different implementation than the sync code. So the ?async syntax will often be not applicable
  • You mention ?async(Send). I agree. It's weird. Same goes for the awaits of course. They would also not affect the sync case
  • Traits are currently the only source of polymorphism that Rust has that I'm aware of (not counting macros). There's beauty to that. Edit: Corrected by @Centril

My conclusion: I don't think that it makes sense to add async polymorphism to the language.

2 Likes

Don't underestimate the effect of small paper-cuts repeated many times. After all, there's a reason it is fn and impl instead of function and implementation.

Haha, sure why not :smiley: Time will tell?

Yes. Why would they not be able to? This is no different than the following, which is legal today:

trait Bar { type Assoc; }
type Foo<T> = Box<dyn Bar<Assoc = T>>;
type Baz = Foo<u32>;

The only difference is that the kind of Foo would be type -> trait (trait = type -> constraint) instead of type -> type.

This is effect polymorphism, which is different than mut polymorphism. See Do be do be do, Conor McBride et. al for a discussion on such things.

Presumably there would be some light weight syntax to say when you want what.

You could also do:

?async fn foo() -> u32 { .. }

async fn bar() -> u32 { foo() } // we decide that `foo` is async here.
fn baz() -> u32 { foo() } // we decide that `foo` is sync here.

True; for ?async saying such a thing is impossible. However, once you use an ?async function inside an async function, then it is possible as seen above.

It is not just weird, it is meaningless. :slight_smile: However, await!ing inside ?async with await!(expr) when it has been made into sync could perhaps be considered the same as { expr } (provided that expr came from another ?async fn).

That's not true. Rust has two forms of polymorphism, a) parametric polymorphism, for example fn id<A>(x: A) -> A { x } is polymorphic but involves no traits. b) ad-hoc polymorphism in the form of traits. The point of polymorphism is reuse (by specifying reasoning about data and algorithms generally without extraneous details). Consider const fn for example -- unless you want to repeat logic once for const fn and once for fn, then some way to avoid that is necessary. Effect polymorphism is one answer. However, I leave the question of whether I believe in (a)sync polymorphism for Rust unresolved for now.

PS: Maybe we should continue further discussion on async-polymorphism (if you are interested that is...) in a separate thread so it doesn't clutter up this one...

On behalf of all German speakers, please not that exact name (very NSFW)! :grimacing:

trailing -> async T

@Centril, @withoutboats, I agree with (almost) all the points made for the current async fn syntax. However, I think I need to clarify:

The trailing -> async T syntax was intended for function declarations, and on the definition side the "initialization pattern" case - i.e., where a "normal" (immediately executing) function returns an async block. AFAICT there is currently no syntactic support for this case, other than the proposed 'in/'input lifetime above (?). Having to switch from a declaration that says async fn foo() -> T; to an implementation of fn foo() -> impl Future<Output=T> + 'in { ... } and mentally consider them equivalent is not very ergonomic IMHO.

That said, there is an additional issue with -> async T: it's not clear which parameter lifetimes should be included in the resulting type (not every input parameter must necessarily be used in the future).

FWIW, for the "standard" async fn definition I added fn foo() as async T { ... } as a possible alternative, mainly for symmetry, and to emphasize that the function body itself is transformed. Symmetric especially if we allowed specifying the return type of async blocks:

    init_stream();
    return async i32 { stream.next_value() };

(Technically unnecessary, but documenting, and could catch errors when the code is changed)

Regarding "typeof"

How about adding a <fn foo> syntax in some form or another to get the type of a function, rather than a new keyword:

MyStruct<T>::<fn foo<A, B>>::Output     // or thereabouts

Hmm. I'm from Germany. I've never heard of the word. I had to look it up. Maybe we don't use that word in Bavaria? ^^'

It'd look like this:

// Note: Outer return type approach!

// Declaration
fn foo(a: &i32, b: &i32) -> impl Future<Output=T> + 'in;

// Definition
async fn foo(a: &i32, b: &i32) -> impl Future<Output=T> + 'in { ... }

Or with a bound:

// Note: Outer return type approach!

// Declaration
fn foo(a: &i32, b: &i32) -> impl Future<Output=T> + Send + 'in;

// Definition
async fn foo(a: &i32, b: &i32) -> impl Future<Output=T> + Send + 'in { ... }

Solving the bound problem is what this thread is about after all. It's IMO a good solution that fits in well with the rest of Rust.

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.