Pre-Pre-RFC: async fn in trait desugaring

The following:

trait Foo {
    async fn foo(self) -> i32;
}

impl Foo for i32 {
    async fn foo(self) -> i32 {
        async move { self }
    }
}

Should desugar to

trait Foo {
    type foo: Future<Output = i32>;

    fn foo(self) -> Self::foo;
}

impl Foo for i32 {
    type foo = impl Future<Output = i32>;

    fn foo(self) -> Self::foo {
        async move { self }
    }
}

This compiles on nightly with the min_type_alias_impl_trait feature.

One benefit is that the return type of async functions in traits can be named, so you can write e.g.

fn bar<F: Foo>()
where
    F::foo: Send,
{}

What are your thoughts?

P.S. The same desugaring could be applied to -> impl Trait in traits.

1 Like

I believe the issue is that the futures may capture self, therefore requiring a lifetime, which requires GATs. There were further complications described in why async fn in traits are hard such as how to deal with Send bounds, how to name the associated type, complex generic bounds, and supporting dyn Trait.

2 Likes

The problem of generic async fns isn’t addressed. They’d be needing GATs (generic associated types). Also I don’t like the idea of having this be an actual desugaring with the effect of being able to name the returned future. It seems too inconsistent with ordinary async fns where you currently can’t name the returned future. At least one should thing about whether there couldn’t be any unified approach to naming function types and/or function return types. Especially having a type with the same name as a function but the type is (only) referring to the return type of that function seems somewhat weird.

Also I have a feeling that for generic async fn trait methods if they’re realized with GATs you still cannot really formulate constraints like e.g. “the returned future is Send whenever the function parameter is Send”, etc, with the current capabilities of Rust’s type system. And I somehow have the feeling that there might be situations where you want to formulate such a constraint.

Other than that, I do feel that—yes, of course—the kind you desugaring you suggest here is, in principle, the correct one. E.g. even without it being an actual desugaring with a namable associated type, if could still be treated “like an associated type” internally.

1 Like

It's actually consistent with tuple structs:

struct Foo(i32);

desugars to a type Foo and a function with the same name which returns the type Foo.

You are right, but I think this desugaring is forwards compatible with GATs and generic async functions in traits. I.e. when GATs become available, you'll be able to write

trait Foo {
    type foo<T>: Future<Output = T>;

    fn foo<T>(t: T) -> Self::foo<T>;
}

I'm not sure if this can even be expressed with Rusts type system. My intuition is that this would require dependent types. This proposal doesn't attempt to solve this problem, could you explain why it is relevant here?

I think currently the best solution would be to have two functions:

trait Foo {
    async fn foo() -> i32;

    fn foo_send() -> impl Future<Output = i32> + Send;
}

It's unfortunate that this so verbose. An alternative syntax could look like this:

trait Foo {
    async(Send) fn foo_send() -> i32;
}
// desugars to
trait Foo {
    type foo_send: Future<Output = i32> + Send;

    fn foo_send() -> Self::foo_send;
}

That’s an interesting point.


I think it’s relevant because ordinary async fns often (implicitly) have those properties as part of their public interface. If I write an async fn foo<T>(x: T) and then hold a value of type T over an await point, then the Future returned by foo::<T> has some kind of impl<T> Send for {future of foo::<T>} where T: Send implementation (and similar for Sync). It’s unfortunate enough that this status quo makes it way too easy to inadvertently introduce breaking changes for ordinary async fns, but if we tried to continue this “everything is implicit” approach with async methods in traits I think we’re going to get even more significant problems. It should probably work fine as long as traits are only used for “overloading” function calls with concrete types; but once you’re writing code that’s generic over a trait with async methods, you don’t get any information anymore about when Send etc. is implemented.

Your example with an explicit Send bound on a nameable return type does seem do demonstrate an approach that works for some generic cases. I haven’t got any experience or spent too much thoughts on what kinds of problems might arise in general. Maybe your approach even always works, but it might in any case involve a bunch of extra bounds on every generic function or method that’s involved. If a trait with a async fn foo<T>(x: T) method intends to only ever be implemented in such a way that T: Send implies {future of Self::foo::<T>}: Send, then having additional Self::foo<T>: Send bounds everywhere seems a bit inelegant.

Also, e.g. considering “prior art”, the async-trait crate seems to just unconditionally always require all the futures to be Send. That’s probably a compromise but also probably not for no reason.

2 Likes

Following my previous thoughts, I can think of two alternative approaches to consider

  • One could try to introduce some way of annotating Send and Sync implementations on async fns in a way that solves both the problem of “how does a trait with an async fn method specify under which conditions the returned future should be Send/Sync/etc” AND the problem of “how can I better avoid breaking changes in ordinary async fn”. Some kind of additional syntax for async fn that’s optional on ordinary async fn but mandatory with async trait methods.

  • One could also encourage a manual use of associated types and only allow the async fn syntax in the implementation. This way one could at least write something like
    trait MyTrait: Send {
        type SomeGenericMethodFuture<T: Send>: Future<Output=()> + Send;
        fn some_generic_method<T: Send>(self, x: T) -> Self::SomeGenericMethodFuture<T>;
    }
    
    and then implement it with
    struct Bar;
    impl MyTrait for Bar {
        async fn some_generic_method<T: Send>(self, x: T) {
            /* ... */
        }
    }
    
    “desugaring” to, roughly,
    impl MyTrait for Bar {
        type SomeGenericMethodFuture<T: Send> = impl Future<Output=()>;
        fn some_generic_method<T: Send>(self, x: T) {
            async move {
                let _ = self; // we want to make sure that *all* the arguments are
                let _ = x;    // being captured (for consistency with normal `async fn`)
                /* ... */
            }
        }
    }
    
    of course, this kind of trait is then committed to only work for Self: Send and T: Send settings. It cannot specify the requirement “I only require the return type to implement Send if both Self and T implement Send”.

async-trait does allow you to remove the Send bound with #[async_trait(?Send)]

One could also encourage a manual use of associated types and only allow the async fn syntax in the implementation .

That sort of de-sugaring feels subpar to be built into the language Ideally async fns in traits would work the same as in regular functions.

The problem is less about whether the proposal is forward compatible with GATs, and more that we need GATs to even write a useful implementation of this.

trait Database {
    async fn get_user(&self) -> User;
}

// desugars to
impl MyDatabase {
    // note the lifetime
    fn get_user(&self) -> impl Future<Output = User> + '_;
}

// desugars to
trait Database {
    // GAT
    type GetUser<'s>: Future<Output = User> + 's;
    fn get_user(&self) -> Self::GetUser<'_>;
}

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.