A lot of your examples are talking about how lifetimes work within a function body, and how that interacts with lifetimes parameters on structs. How lifetimes and borrow checking more generally works in function bodies can be complicated to explore in depth, as it's almost entirely based on inference. Your code implies certain constraints between lifetimes (like 'a
must outlive 'b
, for a couple lifetimes which are frequently inferred and not even actually nameable), and the compiler also checks the use of every borrowed value to see if it conflicts with the inferred active lifetimes.
From reading your question and reponses, I think there's some confusion between these inferred lifetimes, lexical scopes, and how it ties in with lifetime annotations on data structures.
Let me modify the code in your OP and point some things out. The first thing I'm going to do is to switch from using Copy
types like i32
to String
, where the ownership semantics are more clear. (I won't deal with copies in this post, just moves.)
fn main() {
// these variables are *declared* in scope1
let v1 = String::from("10");
let v2 = String::from("20");
let v3 = {
// this one is declared in scope2
let v4 = String::from("30");
// This moves `v1`'s value into this scope
let v5 = v1;
// v1 is no longer usable, it got moved out of
// v2, v4, and v5 are usable here
println!("{v2} {v4} {v5}");
// This moves from `v4`; the value will then be in `v3`
v4
}; // v5 (containing the value originally in v1) drops here
// v2 and v3 still usable
println!("{v2} {v3}");
}
Just because a variable was declared in a given scope doesn't mean that it will be usable for the entire scope, or that the value in the variable can't move moved into a different scope.
When you move a variable, like
let v5 = v1;
Then any borrows of that variable can no longer be valid. This can happen anywhere in your code, like in the middle of a block. If v1
had been borrowed in &'r1 r1
, the lifetime 'r1
could not be active when the move happens -- if something implied it needed to be, you would get a borrow check error. So in a valid program, 'r1
's lifetime must end before that move -- even if that means the lifetime ends in the middle of a block. The general term for this analysis is non-lexical lifetimes (NLL).
fn main() {
let v1 = String::from("10");
let r1 = &v1.as_str(); // &'r1 str ------------+
{ // |
// 'r1 must still be valid here |
println!("{r1}"); // |
// |
// 'r1 can't be active when `v1` moves +---+
let _v2 = v1;
// If you try to use it afterwards, you'll
// get a borrow error
// println!("{r1}");
}
}
'r1
started in the outer block, was valid for the first line of the inner block, but isn't valid for the entire inner block and isn't valid in the outer block after the inner block either.
You could remove the inner block and the analysis would be exactly the same in this case.
The main interaction between lexical scopes (like these nested blocks) and lifetimes is that when a variable goes out of scope at the end of a block, it counts as a use just like moving the variable does. So the scope a variable is declared in puts a cap on how long a borrow of that variable might be -- at the end of the scope, you'll either be moving it to return it to a different scope, or you will have already moved it, or it will implicitly go out of scope and drop. But this is an upper limit for the lifetime. The actual lifetimes are all inferred by
- How the borrows are used (e.g. printing
r1
) - Inferred or explicit constraints between lifetimes (like
'a: 'b
)
Where do these other constraints come from? Some are based on how the borrows are created:
let a = &value; // 'a
let b = &*value; // 'b
Here b
is a "reborrow" of *value
through a: &'a _
, and the reborrow comes with the constraint that it can't outlive the original borrow, so 'a: 'b
is implied.
But constraints come also result from what functions you call or what structs you use.
struct Same<'a>(&'a str, &'a str);
fn main() {
let v1 = String::from("10");
let v2 = String::from("20");
let same = Same(&v1, &v2); // Same<'same>
let Same(r1, r2) = same; // (&'same str, &'same str)
let _v1 = v1; // 'same can't be valid anymore
// Borrow check error for similar reasons as the last example
println!("{r2}");
}
When we create same
:
let same = Same(&v1, &v2); // Same<'same>
// ^ ^
Our definition says that these two lifetimes have to be the same lifetime. That means that if one of them becomes invalid, they both become invalid. The move of v1
means r1
can't be valid any more, and due to the constraint Same<'a>
imposed, r2
can't be valid either. Hence the borrow check error.
But if we allow them to be different:
struct Diff<'a, 'b>(&'a str, &'b str);
fn main() {
let v1 = String::from("10");
let v2 = String::from("20");
let diff = Diff (&v1, &v2); // Diff<'r1, 'r2>
let Diff(r1, r2) = diff; // (&'r1 str, &'r2 str)
let _v1 = v1; // 'r1 can't be valid anymore
// But `'r2` can still be valid -- there is no constraint that the lifetimes
// are the same anymore
println!("{r2}");
}
This version compiles because we've removed the problematic constraint.
If you make this change:
-struct Diff<'a, 'b>(&'a str, &'b str);
+struct Diff<'a: 'b, 'b>(&'a str, &'b str);
+// ^^^^
You will again get an error, because the lifetime of 'r1
has now become an upper limit for the lifetime of 'r2
.
That being said, we have to admit that this is a relatively artificial construction. A single lifetime is usually sufficient for a struct that has only shared references like Same
or Diff
, because
- it is covariant in the lifetime -- the lifetime can be "shrunk" automatically
- references are automatically reborrowed in many places, and those can shrink too
For instances, here's the Same
example modified to work. (Like we said, it can be complicated to explore in depth.)
Hopefully that lends some insight on how lexical scopes aren't the end-all and be-all of lifetime inference, and how different lifetime declarations on structs can lead to different constraints being inferred by the borrow checker.
Just like having one or multiple lifetimes can specify different requirements for a function signature, having one or many lifetimes parameters on a struct can specify different requirements for its use. The borrow checker will infer constraints based on those requirements.
If the compiler can't infer lifetimes that satisfy all the use sites and all the lifetime constraints, it issues a borrow check error. The constraints created by how you choose to declare your struct are instructions to the compiler on how it should determine which programs compile or not.