What happens in the compiler when passing an argument into a function with a lifetime parameter?

// Here a reference to `T` is taken where `T` implements
// `Debug` and all *references* in `T` outlive `'a`. In
// addition, `'a` must outlive the function.
fn print_ref<'a, T>(t: &'a T) where
    T: Debug + 'a {
    println!("`print_ref`: t is {:?}", t);
}

fn main() {
    let x = 7;                     // line 1
    let ref_x = Ref(&x);     // line 2
                                       // line 3
    print_ref(&ref_x);        // line 4
    print(ref_x);                // line 5
}

In the code above, the comment says that "Here a reference to T is taken where T implements Debug and all references in T outlive 'a."

I wonder what is the value of 'a at this point?
I think it is empty because it is a parameter, which we need to pass a value into, and we haven't yet.

Then, when calling the function in print_ref(&ref_x);, what actually happens in the compiler?

My assumption is:

  1. Pass reference &ref_x into the function, therefore the argument t is &ref_x now, and the generic type T is Ref now.
  2. Figure out the lifetime of the passed-in reference in T, which is &ref_x. Its lifetime is from line 4 in the main function to the last line of the print_ref function.
  3. Assign the lifetime in the last step to the lifetime parameter 'a, which is an operation like 'a = (from line 4 in the main function to the last line of the print_ref function)
  4. Assign lifetime 'a to parameter t, which is like lifetime_of_t = (from line 4 in the main function to the last line of the print_ref function).
  5. Check if t's lifetime is longer than the last time it is used. The last time it is used is the last line of the print_ref function, and its lifetime ends exactly after that, so its lifetime is long enough, so everything is valid.
  6. Run the function.

I wonder if my assumption is correct?
Thanks in advance!

Ref is not a type. The full type would be Ref<'b, i32> for some lifetime 'b.

What lifetime is this 'b? That’s an interesting question you could think about, too. Incidentally, with T being Ref<'b, i32>, the constraint T: 'a reduces to 'b: 'a, so it is this lifetime 'b that’s (in this case the only thing) that needs to “outlive 'a” in order to meet the constraint here.

When analyzing lifetimes in a function, it’s best to think locally. Lifetimes within main are only referring to code sections within main, but function calls, like the one to print_ref, count as inseparable wholes, so “from line 4 in the main function to the last line of the print_ref function” is a questionable description of a lifetime, since that starts and ends in different function bodies.

So rather than saying it ends as “the last line of the print_ref function”, it would be more accurate to say it ends “immediately after the print_ref call in main”.

Also note that lifetime parameters essentially only encode the end of a lifetime. Two lifetimes that start at different points but end at the same would be considered the same lifetime by the compiler. This can occasionally be surprising, but often makes no difference. But it also simplifies the analysis a bit if you only have to think about one end. (On the other hand, for borrow checking in general, of course it’s both important when borrows end and when they start, otherwise one couldn’t identify where borrows do or do not overlap in problematic ways. The point I’m making is only about the “meaning” of lifetime parameters [those things with names starting with a quotation mark] in particular.)

Here, too (again), it’s important to recognize borrow-checking as a local analysis step. The function print_ref is borrow-checked independently of the function main. If you’re describing the borrow-checking of main (around the call to print_ref), then no reference to internals of the function print_ref must be made; all you can use of print_ref is its function signature.

Ultimately, the kinds of things that are checked are plentiful, but I assume you don’t claim to be describing any sort of complete picture of all checks involving the lifetime used as parameter 'a for the print_ref call in main.

The kind of thing being checked that sounds most like the thing you describe, i.e. that the reference is not used for longer than its lifetime, does not involve looking into print_ref and seeing that t is no longer used after the end of the last line of that function. Instead, looking at the type signature of print_ref is sufficient to determine that the reference can’t be used beyond the function call. How this can be seen from the signature is perhaps best illustrated by looking at examples where this isn’t so easily the case: Counter-examples of function signatures where further usage after the call could be possible include

  • the same lifetime appearing in the return type, like fn f<'a, T>(t: &'a T) -> &'a i32 – then usage of the return value counts (indirectly) as usage of t
  • the same lifetime appearing in other argument types (at least if it’s not covariantly), for example in a mutable reference’s target, like fn f<'a, T>(t: &'a T, out: &mut &'a i32). This situation is very similar as the previous case, since mutable references can constitute “out parameters” and act like return values. Calling such a function will mean that afterwards, after f was called, usage of the target of out counts (indirectly) as usage of t
  • instead of the same lifetime, “smaller” lifetimes, like lifetime 'b with a 'a: 'b bound, will do the same
    • for fn f<'a, T, 'b>(t: &'a T) -> &'b i32 where 'a: 'b like the first case, usage of the return value will count as further usage of t
    • same idea for something like fn f<'a, T, 'b>(t: &'a T, out: &mut &'b i32) where 'a: 'b

Executing the code is not something that should be part of a listing of “what happens in the compiler”. Execution happens at run-time, the rest would be at compile-time.

1 Like

Below is my rectified understanding, could you please take a look and tell me whether it is correct or not?

  1. The lifetime 'b in let ref_x = Ref('b, &x) is (ends after line 5).

  2. After passing &ref_x into the function, T becomes Ref<'b, i32>, and the constraint in the function signature becomes Ref<'b, i32>: Debug + 'a, meaning 'b > = 'a, in other words, 'a < = 'b.

  3. So, 'a is also ends after line 5.

  4. Then, assign 'a to t, which is like lifetime_of_t = (ends after line 5).

  5. Next, check whether t's lifetime is long enough or not, and the compiler finds that its lifetime (ends after line 5) is not even relevant here, because the function signature shows that it doesn't return anything, nor contains any mutable parameters.

  6. So everything looks fine, compilation is successful.

At the point of declaration? Perhaps think of it like this. The function defines a contract between the caller and the callee. In this case the contract says that the caller must provide:

  • An argument &'a T for some 'a and some T, where
  • T: 'a (implicitly required for &'a T to be valid, also an explicit bound in this case)
  • T: Debug (explicit bound)
  • T: Sized (an implicit bound you can remove, but the callee hasn't removed it)
  • 'a is longer than the function body (so &'a T is valid everywhere within, and that's just how caller-chosen lifetimes work)

And the callee, the function body, can assume all those things are true... but nothing more. Moreover, the function body must make sense and compile for any possible lifetimes and types that meet those bounds.

Because of that, the function body can be checked once without the lifetime and type parameters being given concrete types/"values" yet. Unlike some languages, Rust strives to minimize "post-monomorphization errors", where you only get an error when you call the function with specific types/lifetimes. If you do something invalid in the function body, generally speaking, it won't compile -- even if no code calls the function (so the types/lifetimes are never monomorphized). Example.

So there is no specific lifetime around when the function body is checked; it is checked in such a way that it has to work for any lifetime which it can be called with. (And any type it can be called with.)

Lifetimes can't influence code that compiles, so the function only needs to be fully compiled for every concrete type it's called with after erasing lifetimes (so no matter how many lifetimes you call it with, if you only pass in a &Ref<'_, i32>, the function need only be fully compiled once).

Borrow checking within a function is pretty involved, but in broad strokes how it works today is it

  • Computes a bunch of constraints ('x: 'y) that tie lifetimes together (including many implicit lifetimes)
  • Uses those to compute which places (e.g. variables, fields, dereferences) are borrowed where
  • Checks every use of a place to see if it conflicts with the borrows
In too much detail...
let ref_x = Ref(&x); // : Ref<'a, i32>
// Also note that `x` is borrowed for `'a`

print_ref(&ref_x); // parameter is `&'b Ref<'c, i32>` where
// - `'c: 'b` (required for reference validity)
// - `'a: 'c` (covariance; you can just say `'a = 'c` if you want)
// Also check that the call is valid...
// - `Ref<'c, i32>: 'b` -- so `'c: 'b` -- yep we'll enforce that
// - `Ref<'c, i32>: Sized + Debug` -- yep
// - `'b` outlasts the call -- yep (impossible for it not to)
// Also note that `ref_x`is borrowed for `'b`
// And `'a` must still be alive since `'a: 'c: 'b`
// And also register that we need to generate `print_ref::<'_, Ref<'_, i32>>`

print(ref_x); // parameter is `Ref<'d, i32>` where
// - `'a: 'd` (covariance; you can just say `'a = 'd` if you want)
// Note that `'a` must be alive since `'a: 'd`
//     n.b. but nothing is making `'b` or `'c` be alive here
// And also register that we need to generate `print::<Ref<'_, i32>>`

// Line 6: usage: x goes out of scope

// --------------- Check Uses -------------
`x` is borrowed in line 2 for `'a`.  There's no conflict (e.g. it's not mutably borrowed).
`ref_x` is borrowed in line 4 for '`b`.  There's no conflict
`ref_x` is *moved* in line 5.  There's no conflict (`'b` doesn't have to be alive here)
`x` goes out of scope in line 6.  There's no conflict ('a` doesn't have to be alive here)

In less detail:

// `x` is borrowed for some lifetime `'a`
// `x` isn't exclusively borrowed or anything so this is fine
let ref_x = Ref(&x); // : Ref<'a, i32>

// `ref_x` is borrowed for some lifetime `'b` where `'a: 'b`
// `ref_x` isn't exclusively borrowed or anything so this is fine
// `x` must still be borrowed here, as
// - We used something with `'a` in the type (`ref_x`)
// - Also because `'a: 'b` and we just created `'b`
print_ref(&ref_x);

// `ref_x` is moved which is incompatible with being borrowed
// ...but nothing is causing `'b` to be alive here so this is fine
// ...(`ref_x` no longer needs to be borrowed)
// `x` must still be borrowed (we used `ref_x` which as `'a` in the type)
print(ref_x);

// `x` goes out of scope which is incompatible with being borrowed
// ...but nothing is causing `'a` to be alive here so this is fine
// ...(`x` no longer needs to be borrowed)

Walking through exactly what the compiler does is very tedious -- there are a lot of details around variance, reborrows, deconstructors, exclusive vs shared borrows, and different types of uses. On top of that, the implementation is still getting smarter (making exactly what an inferred lifetime in a function body is more complicated). I'm not sure how fruitful it is to figure out why things do compile when learning Rust. It is certainly useful to learn over time why certain things don't compile. But the vast majority of the time, borrow checker failures can be more easily and less tediously explained than borrow checker successes.

That said, if you're still determined to give a shot, a lot of detail on how the current checker works is in the NLL RFC. It's not exact though; not everything ended up being feasible to implement (notably Problem Case #3 is still with us), and the borrow checker has probably evolved here and there since the RFC as well. I understand things pretty well now, but it took many attempts and rereads. I don't really recommend trying to tackle it as part of getting started with Rust. Maybe you could skim it to get a feel.

Or maybe something more accessible would be this recent blog post about the next-generation borrow checker, or the more in-depth introduction blog post. The blog posts also illustrate that the details of the borrow checking example I walked through above are implementation details to some extent; how exactly the compiler carries out borrow checking is subject to change (so long as it doesn't radically change the semantics or introduce unsoundness).


fn main() {
    let x = 7;                              // line 1
    let ref_x: Ref<'b, i32> = Ref(&x);      // line 2
                                            // line 3
    print_ref::<'a, Ref<'b, i32>>(&ref_x);  // line 4
    print(ref_x);                           // line 5
}                                           // line 6

Yep.

Yep. If Ref<'b, i32>: 'a + Debug + Sized didn't hold, the call would fail.

It can end before that -- otherwise, moving ref_x on line 5 would be a problem, because ref_x would still be borrowed.

I'm not sure what this means, but the compiler doesn't really assign lifetimes so much as check that it can prove there's no constraint or usage violations.

I guess you're stepping into the body of the print_ref function here (and bullet 5)? The compiler doesn't do that. print_ref's body is checked independently of the call sites. It has to work for all lifetimes and types you can call it with.

The call site just has to prove it satisfies the contract, which it does (bullet 2).

Technically — nothing. At run time lifetimes don't exist, and they don't influence behavior of the code in any way. It's possible to remove lifetimes from Rust entirely, and still have code running exactly the same way — that's what mrustc compiler does.

Lifetimes only describe what the code does anyway, and are a redundant piece of information to cross-check that the behavior of code and the description via lifetime annotations match.

Thanks for your explanation! I think I can understand most of it.
For now, my feeling about the official doc is that it is not good at all...
Like in this case, the example (the following function) in the doc demonstrates the usage of a lifetime 'a, but this lifetime doesn't take any effect actually, then what can this example prove? This isn't a real-world usage.

fn print_ref<'a, T>(t: &'a T) where
    T: Debug + 'a {
    println!("`print_ref`: t is {:?}", t);
}

And I don't think anywhere in the doc explains how lifetimes work internally step by step.
The more I read it the more frustrated I get...
Fortunately there are ppl like you to help. Thanks again!

I don't know that I've seen introductory material (official or published books) where I was completely happy with the presentation of

  • Exclusivity, mutability, shared mutability, and how it all ties together
  • Borrows, lifetimes, value scopes, destructors, and how it all ties together

but some are better than others.

No introductory guide covers intrafunction lifetimes in depth; it's too complicated. I do prefer those two don't drastically oversimplify or conflate concepts though.


That said, I ended up reading the Rust By Example book, and it's definitely less polished and more dated than the main Book. The whole thing could benefit from some care and attention.

You can read my notes here. I'll be looking into getting these notes applied to RBE, the maintainers and my free time be willing. So the notes may[1] change or go away as they become irrelevant.


  1. hopefully! ↩︎

Why do we describe 'a and T with the word some?

Because they can be anything that meets the bounds.

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.