Question regarding liveness scopes

Hey folks,

out of curiosity I was looking into the dynosaur crate and played around with it. When I use cargo expand on the following trait:


#[dynosaur::dynosaur(DynTest)]
trait Test {
	async fn doIt(&self) -> String;
}

... I get the following DynTest struct from dynosaur:

#[repr(transparent)]
pub struct DynTest<'dynosaur_struct> {
	ptr: dyn ErasedTest + 'dynosaur_struct,
}

This led me to a more general question: Why dyn ErasedTest + 'dynosaur_struct and not something like dyn ErasedTest<'dynosaur_struct>?

I know the difference between + 'a and ...<'a> from the old captures trick from RPIT but unfortunately that doesn't help me to understand this completely. I have an assumption but I don't know if it's correct:

'dynosaur_struct shall act as the liveness scope of DynTest<'_> here. dyn ErasedTest + 'dynosaur_struct implies dyn ErasedTest: 'dynosaur_struct and that also implies DynTest<'_>: 'dynosaur_struct. Something like


#[repr(transparent)]
pub struct DynTest<'dynosaur_struct> {
	ptr: dyn ErasedTest<'dynosaur_struct>,
}

... would not give the inner trait object (exactly) the same liveness scope as DynTest<'_>...

Is that correct what I'm saying or nonsense?

Regards
keks

If this is meant as a general question as to why the dyn lifetime is syntactically denoted with + 'a instead of an added parameter on the trait name, the reason is that dyn Trait + 'a represents an erased type that meets a Trait + 'a bound (and dyn Trait + Send + 'a a Trait + Send + 'a bound, etc).

If you mean why doesn't that crate in particular rewrite your trait to have an extra lifetime parameter, well,

  • there's no need for an extra lifetime in the trait itself
  • it would still need the dyn lifetime to support type erasing non-'static types
  • the lifetime in the trait parameter would be invariant in DynTest
    • so using the same lifetime for both would be strictly less flexible than not having the lifetime parameter on the trait (as the dyn lifetime is covariant[1])

I look at a few specific examples at the end of this reply.


Lifetimes don't denote liveness scopes. One can think an outlives bound[2] as an approximation of value lifetimes, but personally I feel that still results in more confusion than useful intuition.

At any rate, types are only valid while all their parameters, including lifetimes, are valid. That's true for DynTest<'_> and also every other type.

The way outlives bounds work is:

SomeType<'a, 'b, C, D>: 'x
  if and only if
'a: 'x,
'b: 'x,
 C: 'x,
 D: 'x

And analogously for dyn types.

dyn Trait<'a, 'b, C, D> + 'e: 'x
  if and only if
'a: 'x,
'b: 'x,
 C: 'x,
 D: 'x,
'e: 'x

dyn ErasedTest + 'dynosaur_struct: 'dynosaur_struct is a (trivially) satisfiable bound because of how outlives bounds work.

dyn ErasedTest on its own still has an elided lifetime. If you wrote this bound down:

where
  dyn ErasedTest: 'dynosaur_struct

It is short for

where
  dyn ErasedTest + 'static: 'dynosaur_struct

Which is also a trivially satisfiable bound. It also meets a : 'static bound.

It doesn't mean the erased value lives forever. It means the type of the erased value meets a 'static bound. Like String does, say.

(N.b. the elided dyn lifetime is not always 'static.)

The borrow checker is a pass-or-fail test that can't change the semantics of a program. You can change which programs are accepted by changing lifetimes in your source code, but you can't change where a value gets destructed. Which is what "give the object a liveness scope" sounds like to me.

Here's some different possibilities and how they differ from what the macro does.

// (A): What you got out of the macro
trait ErasedTest { .. }
struct DynTest<'a> { ptr: dyn Erasedtest + 'a }

// (B): Adding a parameter, using `+ 'static`
trait ErasedTest<'a> { .. }
struct DynTest<'a> { ptr: dyn ErasedTest<'a> /* + 'static */ }

// (C): Adding a parameter and equating it to the `dyn` lifetime
trait ErasedTest<'a> { .. }
struct DynTest<'a> { ptr: dyn ErasedTest<'a> + 'a }

// (D): Adding a distinct parameter
trait ErasedTest<'b> { .. }
struct DynTest<'a, 'b> { ptr: dyn ErasedTest<'b> + 'a }

B would disallow type-erasing implementors that do not meet a 'static bound, and would add an unused invariant lifetime parameter to DynTest (which you'd always want to be 'static).

C would be more similar to A, but the lifetime parameter becomes invariant.

D would allow the dyn lifetime to stay covariant, but like B adds an unused invariant lifetime parameter to DynTest. DynTest<'a, 'static> from D would be equivalent to DynTest<'a> from A. The added lifetime parameter serves no purpose, so DynTest<'a, 'static> would always be what you want.

In all cases, the outlives bounds / validity of the type works as was described above. It doesn't matter where the lifetimes go inside the struct definition.


  1. and then some ↩︎

  2. which is a type-level property ↩︎

3 Likes

Thank you very much! :slight_smile: This answers my question completely! :+1: :1st_place_medal:

1 Like