Does lifetime variance happen in methd call expression

use std::marker::PhantomData;

struct CovarLifeTime<'a> {
    marker: PhantomData<&'a ()>,
}

impl<'a> CovarLifeTime<'a> {
    fn method_call<T>(&'a self, t: T) where T: 'a {}
}

fn main() {
    let long = CovarLifeTime{ marker:PhantomData};
    let short = String::from("abc");
    long.method_call(&short);
    
}

Here is a short example show method call expression care about lifetime variance.

long variable has longer lifetime than short. CovarLifeTime<'a>::method_call constraits that second parameter t should outlive than `a. But this code compiles successfully.

NOTE: CovarLifeTime is covariant over 'a. Is there any lifetime variance in method call expression? Is there any reference telling about this ?

As far as I know, the nomicon has the only comprehensive documentation on lifetime variance in rust

I've read about this. It only tells what is variance, but not tell in which context it will happen.

Variance is a property of the types, not a property of where they are used.

Yes, I know that. But can you explain why the example compile successfully?

I truly know that CovarLifetime is covariant over lifetime parameter 'a. But I don't know why method_call is called successfully.

CovarLifeTime is covariant over 'a, which means that the borrow checker can choose a shorter lifetime for the method call.

If we expand the method call syntax it may be clearer what's happening

use std::marker::PhantomData;

struct CovarLifeTime<'a> {
    marker: PhantomData<&'a ()>,
}

impl<'a> CovarLifeTime<'a> {
    fn method_call<T>(&'a self, t: T) where T: 'a {}
}

fn main() {
    // Pretend we can give the lifetimes arbitrary names here
    let long: CovarLifeTime<'long> = CovarLifeTime{ marker:PhantomData};
    let short = String::from("abc");
    CovarLifeTime::<'short>::method_call(&long, &short);
    
}

The lifetime selected for the method call doesn't have to be the same lifetime present on the specific struct we call the method on, it just needs to satisfy the constraints of the method call.

Variance is not relevant to why this code compiles. Proof

2 Likes

That's only true if you aren't passing a reference to CovarLifeTime to another function. If you split the short part into it's own function the variance of 'a on CovarLifeTime changes whether or not it compiles

1 Like

My first thought is that you may be confusing value liveness scope with lifetime validity bound on a type. But, I'm not actually sure that's the case. Why do you think the lifetime of the type of long is longer than the borrow of short though? There's nothing in the code enforcing that constraint.

Anyway, let's look at this like a compiler might. The code works because the compiler could infer lifetimes that met all the implicit and explicit constraints.

fn main() {
    // Let's say this is a CovarLifetime<'?0>
    // The lifetime to use is going to be inferred
    let long = CovarLifeTime { marker: PhantomData };
    let short = String::from("abc");
    // Let's say this is a &'?1 String
    // The lifetime to use is going to be inferred
    let borrow = &short;
    // Let's say this is CovarLifeTime::<'?2>::method_call::<&'?3 String>
    // And again, `'?_` lifetimes are going to be inferred
    long.method_call(borrow);
}

And we have

  • '?2 and '?3 have to be valid on the last line (but no where afterwards)
  • We must have '?0: '?2 so the long is still valid on the last line
  • We must have '?1: '?3 so the borrow is still valid on the last line
  • We must have &'?3 String: '?2 as per the method constraint
    • So we must have '?3: '?2, and transitively, '?1: '?2

And I believe that's it:

  • '?0 : '?2
  • '?1 : '?3
    • '?3 : '?2
    • '?1 : '?2

Any lifetime that meets these constraints is fine. One simple choice would just be to pick the same lifetime for everything! Some lifetime that ends immediately after the last line.


What about if your struct is invariant? Then we must have ?'0 == ?'2.

  • '?0 : ?'0 (always true)
  • '?1 : '?3
    • '?3: '?0
    • '?1: '?0

This is still satisfiable, and just making all the lifetimes the same still works. So the variance of the struct doesn't matter for your OP.


Let's look at why it matters in @semicoleon's last playground.

// n.b. the original had `'a: 'b` which implies `'a == 'b`
// I've assumed that was a mistake and removed it
// There is some related conversation further down
fn short_fn<'a, 'b>(long: &'a CovarLifeTime<'b>) {
    let short = String::from("abc");
    let borrow = &short; // '?1
    // CovarLifeTime::<'?2>::method_call::<&'?3 String>
    long.method_call(borrow);
}

No matter what:

  • 'b: 'a (implied by the existence of a &'a CovarLifeTime<'b>)
  • 'a may be strictly less than 'b though, as the function says they can be different
  • 'b and 'a outlive the function (as they are lifetime parameters from the caller)
  • '?1 and '?3 cannot outlive the function as they reference a local variable
  • Therefore '?1 : 'b and '?1 : 'a and '?3: 'b and '?3: 'a can never be satisfied

With covariance:

  • 'a: '?2 so the method is well-formed
  • '?1: '?3 so the borrow is well-formed at the method call
  • '?3: '?2 by the method constraint

And all together:

  • 'b : 'a
    • 'a : '?2
    • 'b : '?2
  • '?1 : '?3
    • '?3 : '?2
    • '?1 : '?2

Inferring a lifetime that ends at the last line of the function and using that for all inferred lifetimes still works. How about invariance? That means that '?2 == 'b, as 'b is the invariant lifetime.

  • 'b : 'a
    • 'a : 'b Can't be satisfied because we said they didn't have to be equal
    • 'b : 'b
  • '?1 : '?3
    • '?3 : 'b Can never be satisfied because '?3 is local and 'b isn't
    • '?1 : 'b Can never be satisfied because '?1 is local and 'b isn't

That 'a: 'b point means that even this won't work:

// Actually invariant
fn short_fn<'a, 'b>(long: &'a CovarLifeTime<'b>) {
    long.method_call("");
}

However this does:

// Actually invariant
// Equivalent to the original `short_fn<'a: 'b, 'b>(&'a ... <'b>)`
fn short_fn<'b>(long: &'b CovarLifeTime<'b>) {
    long.method_call("");
}

But this will still have the "can't borrow a local for longer than the function body" problems.

// Actually invariant
fn short_fn<'b>(long: &'b CovarLifeTime<'b>) {
    let short = String::new();
    long.method_call(&short);
}

So there's two things making the invariant case problematic:

  • We can't coerce &'a S<'b> to a &'a S<'a>
    • This is a problem with the function signature -- we can never call the method that requires the lifetimes to be equal
    • So the only useful signature is to take a &'a S<'a>
  • We can't coerce a &'a S<'a> to a &'local S<'local>
    • This means we can't pass in local borrows to the method, even with a somewhat useful signature

They aren't a problem in main because all the lifetimes are free to be inferred, not dictated by the function signature.

1 Like

Wow! Amazing Answer! Compiler works like a liftetime constraits resolver. But is it a little bit anti-ergonomical? Because it is easy for program but hard for human brain to think and resolve the constraits.

In an ideal world, the compiler could always figure out whether the constraint can be satisfied and us human has no need to even think about it. Nowadays, this is true for most cases, but there's still some corner cases the compiler cannot handle. There's still continuous effort working on making the compiler (nearly) perfect.

2 Likes

Well, there's a push-pull between making as many things work via inference so that you don't get as many borrow errors you have to guide the compiler through, and humans being able to figure out what exactly got inferred. With lifetimes in particular, the idea is mostly to ensure memory safety. In a sense, by not specifying lifetimes and leaving them to be inferred, you're asking the compiler to prove it's sound but also saying you don't care about the particulars. [1]

I'd say it's pretty ergonomical when it works -- when you're trying to get something done in Rust [2], after all, you're writing code you expect to compile. You're usually not looking at a successful compilation and going "hmm, how did it figure out that is memory safe and satisfies all my bounds?" -- you wrote something in a way you thought made sense, it compiled, next step.

When it doesn't work, I'd say the errors are ok given the complexity of explaining lifetime errors. There's room for improvement, and it does take some experience to understand what went wrong.

The annoying parts IMO are when it can't infer a solution even when there is one, or when there is no solution but you can't figure out what's gone wrong from the error message.

Both the successes and errors were easier to understand before NLL (Non-Lexical Lifetimes) -- that is, the compiler's reasoning itself was simpler -- but also a lot less ergonomic, because you'd have to do things like introducing blocks whose only purpose was to cause a lifetime to be shorter.

Some sort of "show me how you proved it" and "visually show me the lifetimes and/or borrow errors inferred" tools would be cool.


  1. Granted you don't exactly always have a choice, since you can't name local borrow lifetimes. ↩︎

  2. vs poking at the language itself and learning how things work ↩︎

3 Likes

Cannot agree more. Tools only need to show why your lifetime is not valid, not need to show solutions. It is enough for me~

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.