Trait returning a reference tied to Self

Hello, I was writing a trait that should return a reference so some state owned by the implementing structure.
Below are two possible solution I came up with:

  1. Adding a lifetime to the trait
  2. Making the associated type generic using GAT

I was wondering what are the practical difference between those and which one should be preferred (or if there's a better way all together... that works too :smile: )

#![feature(generic_associated_types)]

trait WithLifetime<'a> {
    type A: 'a;
    fn with_lifetime<'b: 'a>(&'b self) -> Self::A;
}

trait WithoutLifetime {
    type A<'a> where Self: 'a;
    fn without_lifetime<'b>(&'b self) -> Self::A<'b>;
}

struct Test(String);

impl<'a> WithLifetime<'a> for Test {
    type A = &'a String;
    fn with_lifetime<'b: 'a>(&'b self) -> Self::A {
        &self.0
    }
}

impl WithoutLifetime for Test {
    type A<'a> = &'a String;
    fn without_lifetime<'b>(&'b self) -> Self::A<'b> {
        &self.0
    }
}

fn main() {
    let test = Test("hello".to_string());
    println!("{}", test.with_lifetime()); 
    println!("{}", test.without_lifetime()); 
}

(Playground)

This is actually a "simplified" example, in my codebase the type is actually

type A: Deref<Target = Other>

because one of the implementation of the trait should return a reference guarded by a mutex guard, but I don't think that's important for the discussion.

Thanks for any comments, suggestions and critics!

You may be interested in reading:


Basically the gist of it is that you're indeed right these two abstractions are quite related, to the point where one can express generic_associated_types using a trait such as your WithLifetime :slightly_smiling_face:

That being said, in your snippet, there are a few things left to polish to get perfect equivalence:

  • That extra <'b : 'a> is not playing any meaningful role,
    it's gonna be 'b = 'a in practice since the borrow checker tries to pick minimal lifetimes for stuff in input which is otherwise unbounded, such as 'b there, and the minimum of <'b : 'a> is 'a. Or phrased differently: no flexibility is gained from picking a 'b strictly greater than 'a.

    So let's simplify WithLifetime down to it:

    trait WithLifetime<'a> {
        type A;
        fn with_lifetime(&'a self) -> Self::A;
    }
    

    to be matched against:

    trait WithoutLifetime {
        type A<'a> where Self : 'a;
        fn without_lifetime<'a>(&'a self) -> Self::A;
    }
    
  • The where Self : 'a is missing from the WithLifetime trait, which means that if you try to implement it for Mutex<T>, as you mentioned, you'll find yourself having to add a T : 'a bound in the impl (rather than in the trait definition).

    So you'd need to, at the very least, add a where Self : 'a to WithLifetime:

    trait WithLifetime<'a> where Self : 'a {
    

    which can be rewritten with the "supertrait" shorthand:

    trait WithLifetime<'a> : 'a {
    

    At that point the T : 'a bound in the impl is still gonna be usable by downstream people.

    Note that there is another hackier way to achieve this, but which is gonna be relevant for the next bullet: we can use an implicit bound rather than an explicit one.

    macro_rules! Where {( $T:ty : $b:lifetime $(,)? ) => (
        &$b $T // or even `[&$b $T; 0]`
    )}
    
    trait WithLifetime<'a, ImplicitBound = Where!(Self : 'a)> {
    
  • Finally, the key aspect of GATs vs. classic generic traits: higher-order-ness.

    The idea is to consider some type T, generic or not (for starters), which is expected to implement WithoutLifetime or WithLifetime….

    // T : WithoutLifetime or T : WithLifetime<'?>
    fn input<'x, 'y> (
       x: &'x T,
       y: &'y T,
    ) -> (T::A<'x>, T::A<'y>) /* or the non-gat versions */
    {(
        T::with{,out}_lifetime(x),
        T::with{,out}_lifetime(y),
    )}
    

    What is required for this to work?

    • in the WithoutLifetime case, nothing more: the fn without_lifetime<'a> was fully generic, so that <'a> can be picked equal to 'x and 'y respectively, and all is good.

    • but in the WithLifetime case, this will only work if T : WithLifetime<'x> and T : WithLifetime<'y>. It has to implement two "instantiations" of the generic trait!

    Now let's push the situation further, let's involve an "unnameable lifetime" (this happens when borrowing a function local / an internal variable which is owned by some scope and does not outlive the return point):

    fn with_local(new: fn() -> T) {
        let local: T = new();
        let _: T::A<'_> = T::with{,out}_lifetime>(&local);
    }
    

    Now, in this situation, the WithoutLifetime / GAT case is fine, again: fn without_lifetime<'a> is fully generic, so 'a can be picked equal to an unnameable lifetime parameter and all is fine.

    But in the WithLifetime case, we kind of have a problem:

    T : WithLifetime<'???>
    

    what should we write for '??? ? We can't write '_, it's not a thing (yet), but we can't name that lifetime/duration/region during which the borrow is held.

    The solution is Higher-Rank Trait Bounds, or HRTB for short, or higher-order / universal quantification as I like to put it: the idea is to try and go back to the fn without_lifetime<'a> generic situation: that one was able to handle this local-and-anonymous lifetime by virtue of being generic over any possible choice of a lifetime, by virtue of being able to handle any lifetime. Indeed, fn generics, and HRTBs in the trait case, are ways to express that a property holds for any, and this is sufficient (and in practice, necessary) to be able to handle local and anonymous lifetimes like that:

    T : for<'any> WithLifetime<'any>
    

    So we can now see that T : WithoutLifetime is rather close to T : for<'any> WithLifetime<'any>.

You may not have observed that last subtlety (there does not exist a single / fixed 'a that satisfies WithoutLifetime = WithLifetime<'a>), because you may have been working with T = SomeConcreteImplementorType, such as Mutex<T>, with the impl line being:

   impl<'a, T> WithLifetime<'a> for Mutex<T> {
// because `Mutex<T>` does not depend on `'a`, that is equivalent to:
// impl<T> for<'a> WithLifetime<'a> for Mutex<T> { /* pseudo-code */

But if you were to write:

//                    vv       vv
impl<'a> WithLifetime<'a> for &'a str {

you'll notice that we'll quickly run into the problems I mentioned in that third bullet.

And this will be relevant if you suddenly want to be generic over the implementors of this stuff, say Mutex<T>, RwLock<T>, RefCell<T>, and so on:

The bound that you'll need to use, in the GAT case, will be straight-forward:

<Lock : WithoutLifetime>

but in the WithLifetime case, you'll need that for…:

<Lock : for<'any> WithLifetime<'any>>
  • Note that this only works when using an implicit bound for the Self : 'a, as mentioned in the second bullet.

For what is worth, the for<'any> … stuff can be hidden under the rug through a convenient "trait alias".

  • much like serde did it itself: Deserialize<'de> is our WithLifetime<'de>, and when you're deserializing a generic T off locally-bororwed data, you'll notice there is no choice of 'de that makes the T : Deserialize<'de> bound work. In technical terms, you'd need this

    T : for<'de> Deserialize<'de>
    

    but serde hides this using / in the guide, tells you to instead reach for:

    T : DeserializeOwned
    

    Yes, DeserializeOwned is just an alias to hide the more scary-looking for<'de> Deserialize<'de>.

So let's write our alias:

trait WithoutLifetime
where
    Self : for<'any> WithLifetime<'any>,
{}

impl<T : ?Sized> WithoutLifetime for T
where
    Self : for<'any> WithLifetime<'any>,
{}

and now, in the non-GAT case, you can still write <Lock : WithoutLifetime>.

Congratulations, you have managed to emulate GATs using good old generic traits :slightly_smiling_face:


if you can afford nightly, use GATs, they're more readable: they're way better at hiding that complex for-quantification aspect thanks to that inner generic.

If you're not on nightly, imho[1], you ought to use:

to at least give the illusion of GAT in your source code, and seamlessly transition to proper GATs once those are available.

On the other hand, if you want to optimize the idea of "long"[2]-lived borrows to avoid having to perform copies, much like in the Deserialize situation, it can be very beneficial to be able to use the non-universally-quantified variant of the trait: impl<'s> Deserialize<'s> for &'s str {. And in that case, offering a trait alias for the fully quantified variant of the API lets you handle both.


  1. I'm the author of nougat ↩︎

  2. as in, non-local ↩︎

4 Likes