Question about lifetime params in async-trait

I have a lib which has async trait methods. So far I have been using a signature as follows:

pub type Return <'a, R> = Pin<Box< dyn Future<Output = R> + 'a + Send >>;

fn started( &mut self ) -> Return<'_, ()>;

and all was good until I thought it would be nice if at least implementers could use async-trait from @dtolnay, which avoids having to write the async {}.boxed() in every method body. So I checked for compatibility but async-trait has a signature like so:

fn handle<'life0, 'async_trait>(
    &'life0 mut self,
    msg: Add,
) -> ::core::pin::Pin<
    Box<dyn Future<Output = ()> + Send + 'async_trait>,
>
where
    'life0: 'async_trait,
    Self: 'async_trait,
{...}

So to my surprise it uses 2 lifetime parameters, which was incompatible with my prior design.

I don't quite understand why there is a bound on Self: 'async_trait. Wouldn't &'async_trait self already guarantee that Self outlives 'async_trait? How, at least in safe rust would it be possible to take a reference that outlives the actual type?

Also, during evaluation of the method, wouldn't the compiler detect if you tried to return something in the future that doesn't live long enough?

Given I don't understand this, it makes me wonder if my prior approach was wrong, given that I never tried implementing the traits on references or types that aren't 'static...

The multiple lifetimes that async-trait insert make more sense when you have several references as arguments, because then there's no requirement that the arguments have the same exact lifetime, which would otherwise be the case, if you reused it. In your case, it is equivalent to a single lifetime.

To be clear, your approach is perfectly fine.

I can see that if there are other references present, their lifetime must be mentioned, but does that mean that Self: 'bound doesn't make sense if there is also &'bound self?

It's true that there are some redundant things here. Most of them are not necessary in this case, but they don't cause any harm either, so the macro always includes them for simplicity.

Ok, thanks for confirming. The issues start when you want to support both manual implementation and async-trait, like:

  • you have to make the function generic over several lifetimes, which wasn't necessary before
  • the order of the lifetimes is important and the error message is not great
  • you need to add all the where clauses in implementations
  • it's no longer possible to elide the lifetime in the return type or tie it to a specific input lifetime and use other references before the async block and not include them in the async block.
  • it's no longer possible to chose a completely different output lifetime. With a manual async trait fn it's possible to specify that the returned future is 'static, so it can be spawned. You can then use references before the async block and not move them in. Potentially cloning reference counted fields to move those into the async block.

And all that even if one doesn't use async-trait in the declaration of the trait, but just wants to remain compatible with it to allow boilerplate reduction on the client part.

I don’t believe manually specifying either the trait or an implementation is semver guaranteed with async_trait. The exact desugaring may change, which is fine when using the attribute as it will change both simultaneously.

@Nemo157 Yes, you are absolutely right. It's a problem I'm pondering on. The price then just becomes that the proc macro dependency becomes forced, also for people who just depend on the traits (which are in a separate crate), but that's probably an acceptable cost.

Another thing I don't like about async-trait in declaration is that it makes it less readable what is really happening. It kind of obscures the signature of the methods. You need to run cargo-expand on it now to see what it really is. And the breaking change release cycle does become tied to that of async-trait.

Manually implementing can be fine in binary crates if people are ok with the potential breakage.

I ran into another blocker with async-trait. It seems it's not possible to have methods that return Send and !Send futures in the same trait.

The choice of Sendness is made on a trait basis. I do have traits that have both specifically to work around the troubles of Send in boxed trait objects.

There are also issues with default impls with async-trait that I would yet have to look into if they might be blockers.

All in all, I decide to opt out of async-trait. The only thing it's meant to solve is boilerplate on the side of the implementer. I think I can solve that better by creating my own proc macro to specifically generate the return type and wrap the body in async{}.boxed() or async{}.boxed_local().

It will solve the only problem (implementer boilerplate) without all the problems of async-trait for the price of some extra work on my end.

2 Likes

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