Idea of how to bound types that async methods return

We could introduce a new contextual type alias (like Self) to be used in where clauses of async methods. A possible name Return.

2 Likes

What's the motivation for this? Can you provide an example?

Motivation is that future async methods in traits will in fact have anonymous associated types for futures they return. These futures may be wanted to be Send or Sync.
Example:

trait AsyncRead {
    async fn read(buf: &mut [u8])
      where Return: Send; //bound on anonymous return type
}

This desugars to:

trait AsyncRead {
    fn read(buf: &mut [u8]) -> impl Future<Output=()> + Send //note the `Send` bound
}

Alternative proposed syntax I've seen was async(Send) fn read(..), but it's weird and doesn't align with rest of async syntax.

Just an idea.

1 Like

This could be a great stop-gap feature, but definitely risking cluttering the language.

But where is used for conditions, not for guarantees, so it doesn't fit IMO.

1 Like

The expected behavior on impl side is to throw an error if a resulting feature (of an async methood) doesn't satisfy trait's bounds.

Yes, but usually the where conditions must be fulfilled by the caller, not by the implemention of the function.

This is because impl Trait was intended to be a shorthand form for a yet to be defined full form which is still missing. The obvious proper solution is to define said full form. My suggestion would be to add another optional clause to function declarations:

trait<T> AsyncRead {
    fn read(buf: &mut [u8]) -> T
        // where clause if applicable
        given T: Future<Output=()> + Send; // new clause 
} 

[ feel free to bike-shed the actual syntax :wink: ]

Back in the day the other competing design was to draw inspiration from C# (I still maintain that that was a better design choice compared to impl Trait:

trait<in T, out U> Foo {
    fn foo(a: T) -> U; 

where in / out are optional modifiers that signify the type of relationship:

  • in is a "universal T" meaning "for all T" in the logic sense. This would be the default since that's the current behaviour.
  • out is an "existencial T" meaning "exists T" which is the current behaviour of impl Trait in return position.

[we could also bike-shed the names of these modifiers since we already have syntax for universal lifetimes so we should be compatible with that]

Alas, said design was rejected in favour of (imo) a more short-sighted design that doesn't allow a natural extension of the current syntax nor does it harmonise with other features (the universal lifetime syntax I was alluding at before).

Finally, i want to make a side note regarding the specific example: This current design is copying incorrect idioms from C which then further give rise to further feature requests from that same catalogue - "out references". The buf parameter as specified is incorrect as it must be pointing to an initialised buffer (which is wasteful). Instead, why not consider using a closure? i.e:

trait AsyncRead {
    fn read(f: impl FnOnce(&mut [u8]));
}

where the implementer of the trait provides the pre-filled buffer to the closure. (i.o.w like a Ruby block).

Doesn't C# use those keywords for variance? Kotlin also does that. I feel like using them for universal/existential would be rather confusing.

Also, C# (and other OOP languages) don't need existential types because interfaces are already types

2 Likes

One solution I like is using the Output type from the Fn* traits:

trait AsyncRead
where
    Self::read::Output: Send,
{
    async fn read(buf: &mut [u8]);
}

Please read my post again. I specifically said:

Which is not the same as "copy a feature verbatim".

Yes, OO interfaces are not the same as Rust traits but they are analogous and comparable in usage. Variance is used in C# to talk about "input" versus "output" types which explains the name choice. Existential types vs. Universal types in rust play a similar role. There are use-cases for both as argument types and for return types (just like in the variance case in OO) and there is also a clear default mode for each.

Therefore, a good forward looking design should have been to add such optional modifiers. impl Trait is a failure to recognise the apparent symmetry here.

Lastly, as I've already said, the names could be instead: exists/for [all] to be compatible with universal lifetimes.

Just copying over Niko's notes from "why async fn in traits are hard"

Complication #2: send bounds (and other bounds)

Right now, when you write an async fn, the resulting future may or may not implement Send – the result depends on what state it captures. The compiler infers this automatically, basically, in typical auto trait fashion.

But if you are writing generic code, you may well want to need to require that the resulting future is Send. For example, imagine we are writing a finagle_database thing that, as part of its inner working, happens to spawn off a parallel thread to get the current user. Since we’re going to be spawning a thread with the result from d.get_user(), that result is going to have to be Send, which means we’re going to want to write a function that looks something like this1:

fn finagle_database<D: Database>(d: &D)
where
    for<'s> D::GetUser<'s>: Send,
{
    ...
    spawn(d.get_user());
    ...
}

This example seems “ok”, but there are four complications

  • First, we wrote the name GetUser, but that is something we introduced as part of “manually” desugaring async fn get_user. What name would the user actually use?
  • Second, writing for<'s> D::GetUser<'s> is kind of grody, we’re obviously going to want more compact syntax (this is really an issue around generic associated types in general).
  • Third, our example Database trait has only one async fn, but obviously there might be many more. Probably we will want to make all of them Send or None – so you can expand a lot more grody bounds in a real function!
  • Finally, forcing the user to specify which exact async fns have to return Send futures is a semver hazard.

Let me dig into those a bit.

Complication #2a. How to name the associated type?

So we saw that, in a trait, returning an impl Trait value is equivalent to introducing a (possibly generic) associated type. But how should we name this associated type? In my example, I introduced a GetUser associated type as the result of the get_user function. Certainly, you could imagine a rule like “take the name of the function and convert it to camel case”, but it feels a bit hokey (although I suspect that, in practice, it would work out just fine). There have been other proposals too, such as typeof expressions and the like.

Complication #2b. Grody, complex bounds, especially around GATs.

In my example, I used the strawman syntax for<'s> D::GetUser<'s>: Send. In real life, unfortunately, the bounds you need may well get more complex still. Consider the case where an async fn has generic parameters itself:

trait Foo {
    async fn bar<A, B>(a: A, b: B);
}

Here, the future that results bar is only going to be Send if A: Send and B: Send. This suggests a bound like

where
    for<A: Send, B: Send> { S::bar<A, B>: Send }

From a conceptual point-of-view, bounds like these are no problem. Chalk can handle them just fine, for example. But I think this is pretty clearly a problem and not something that ordinary users are going to want to write on a regular basis.

Complication #2c. Listing specific associated types reveals implementation details

If we require functions to specify the exact futures that are Send, that is not only tedious, it could be a semver hazard. Consider our finagle_database function – from its where clause, we can see that it spawns out get_user into a scoped thread. But what if we wanted to modify it in the future to spawn off more database operations? That would require us to modify the where-clauses, which might in turn break our callers. Seems like a problem, and it suggests that we might want some way to say “all possible futures are send”.

Conclusion: We might want a new syntax for propagating auto traits to async fns

All of this suggests that we might want some way to propagate auto traits through to the results of async fns explicitly. For example, you could imagine supporting async bounds, so that we might write async Send instead of just Send:

pub fn finagle_database<DB>(t: DB)
where
    DB: Database + async Send,
{
}

This syntax would be some kind of “default” that expands to explicit Send bounds both DB and all the futures potentially returned by DB.

Or perhaps we’d even want to avoid any syntax, and somehow “rejigger” how Send works when applied to traits that contain async fns? I’m not sure about how that would work.

It’s worth pointing out this same problem can occur with impl Trait in return position, or indeed any associated types. Therefore, we might prefer a syntax that is more general and not tied to async.

1 Like

What we want to express here is:

pub fn finagle_database<DB>(t: DB)
where
   DB: Database + // Database's async methods return Send futures 
{}

Now the last part is reminiscent of another feature of Rust - trait objects. Recall that Rust defines object-safety rules as to when it is allowed to turn a trait into a trait object. Another important bit is that we can specify where Self: Sized conditions on methods to exclude them from the generated trait object. Finally, we have an operator to turn a trait into an object trait.

trait Foo {
  fn dont_need_sized(&self);
  fn need_sized(self) -> Self where Self: Sized;
}
let foo = ...;
let obj = foo as &dyn Foo;

Foo is object safe but need_sized won’t be available to call on a trait object of that type.

For async traits we need a parallel of dyn Trait in async Trait such that it would obey similar structure: e.g.:

pub fn finagle_database<DB>(t: DB)
where 
    DB: Database, 
    async Database: Send; 

where the operator allows us to refer to an "async object trait" of sorts. It would be a type-level construction to serve to refer to the bounds applicable to the async trait itself which really means:

The given async trait's associated types collectively as generated by the compiler for their respective async methods.

We could go further to create an analogue for the where Self: Sized usage - provide a syntax that would expand to additional bounds on the generated associated types. something like:

async trait Foo {
    async fn foo() + Send; // hypothetical syntax
    async fn bar() + Send -> u32; // ditto
}

Would desugar into:

trait Foo {
    type __foo: Future<()> + Send;
    fn foo() -> __foo;

    type __bar: Future<u32> + Send;
    fn bar() -> __bar;
}

Edit: Having said the above I still very strongly feel that we shouldn't entertain an async Trait in isolation just yet. Instead of growing more appendages like impl Trait I much rather see the full form designed and integrated first into the type system. async Trait should be nothing more than syntax sugar on top of a more deeply integrated and a full fledged concept of Rust's type system.

1 Like

Do we really have a problem with impl Trait? We already can bound these anonymous types as usual ones: through impl Traits syntax (it just doesn't work yet, but still).

What you've demonstrated in the examples is exactly what I'm talking about: How to bound an unreferrable type, not just unnamble? We actually want to name an unreferrable types generated by a trait with async methods.

1 Like