For this comment, you mean here? I'll assume so:
fn shrink_the_lifetime<'a>(original_static:&'a i32, used_for_shrink:&'a i32){}
fn main(){
static I:i32 = 0; // 'static -------------------------------------+
let rf; // Liveness scope 'rf ---------------------------------+ |
{ // | |
rf = &I; // &'static_or_shorter i32 --------------------+ |
let ii = 0; // Liveness scope 'ii --------------------+ | |
let rf_block = ⅈ // &'ii_or_shorter i32 --------+ | | |
shrink_the_lifetime( // | | | |
rf, // -- a reborrow &'short i32 --------+ | | | |
rf_block // also a reborrow &'short i32 --+ | | | |
) // The reborrows only have to last for | | | | |
; // the call to the function ----------------+ | | | |
// -------- No more uses of rf_block -------------+ | | |
} // -------- End of 'ii -------------------------------+ | |
rf; // -------- End of 'rf ------------------------------------+ |
} // :
I'm guessing the confusion is, how can rf
still be valid after the inner block if it was forced to have a lifetime limited by the inner block? And the answer is that it wasn't actually limited to the inner block by the call to shrink_lifetime
, because when you pass a function argument, it can be coerced to a subtype (in this case a shorter lifetime), which means that there is an implicit reborrow that goes on here, as if you had typed
shrink_the_lifetime(&*rf, &*rf_block);
So this function call doesn't actually have to restrict the lifetime inferred for the type of rf
; the type of the values passed to the function can be different than rf
and rf_block
(they can have shorter lifetimes).
Reborrows are another one of those woefully underdocumented features of the language.
OK, so. I'm going to forge ahead here in anticipation of follow-up questions, which would be completely reasonable. Understanding reborrows is pretty important to understanding why various programs compile, whereas the rest of this post goes into examples which, in my experience, you probably won't need to worry about from a practical perspective.
So perhaps stop here until you have a firm grasp on reborrows. Or even stop here forever! I ramble on quite a bit.
Anyway, forging ahead. How can we actually limit the lifetime of rf
in some way the compiler doesn't like? Here's one way:
static I:i32 = 0;
let mut rf;
{
rf = &I;
let ii = 0;
let rf_block = ⅈ
rf = rf_block;
}
rf;
It's probably pretty easy to see why this is problematic:
- You put a borrow of
ii
into rf
- You drop
ii
- You use
rf
, but it still points to ii
, which has been dropped
So we could say that rf
had a lifetime that was inferred to end when the inner block did, so the use after the block was invalid.
But you've tenacious about fleshing out the rules, so perhaps you'll push on forward to try this:
static I:i32 = 0;
let mut rf;
{
rf = &I; // (a)
let ii = 0; // (b)
let rf_block = ⅈ // (c)
rf = rf_block; // (d)
rf = &I; // new // (e)
}
rf; // (f)
Now it compiles again, and most mental models of the analysis which also account for strict, static typing (including lifetimes) will fail to explain why this is allowed:
-
rf_block
has a static type with a lifetime limited to the inner block ('rfb
)
-
rf
has a static type with a lifetime that must be valid outside the inner block ('rf
)
- But somehow we could assign
rf_block
to rf
, implying 'rfb: 'rf
This is where things like liveness analysis come in:
- The borrow of
I
at (a)
is doesn't need to exist beyond (a)
- It isn't used after it's created
- The borrow of
ii
at (c)
needs to exist at (c)
and (d)
, where it is created then used
- It gets killed at
(e)
by rf
being overwritten, after which rf_block
no longer used
- The borrow of
I
at (e)
needs to be valid of (e)
and (f)
So what's the lifetime of the static types of rf
and rf_block
? There is no simple answer because NLL lifetimes are codepoint and control-flow sensitive. The lifetimes are perhaps something like
'rf:
at (a), {(a)}
at (b), {}
at (c), {}
at (d), {(d)} // lifetimes are "forward looking", no need to include (b) (c)
at (e), {(e), (f)}
at (f), {(f)} // similarly no need to include (e) here
'rfb:
at (c), {(c), (d)}
at (d), {(d)}
And 'rfb: 'rf
at (d)
.
I must also admit, I'm still being cagey by saying "perhaps" here, because I didn't actually take the time to walk through the entire NLL algorithm as presented in the RFC.
So I'm afraid to say, there is no simple explanation of lifetimes for every scenario. Inferred lifetimes are the output of a static, but liveness and control-flow sensitive, analysis of your code.
The practical approach to getting a deeper grasp on Rust lifetimes, in my opinion, is to build a mental model that explains common patterns and lifetime errors. This mental model will probably be more simplistic than the actual compiler analysis. That is, there may be programs which compile which shouldn't under your mental model, like the example just given. But so long as your mental model is strictly too conservative, this is -- from a practical perspective -- usually still okay. If your program compiles, you generally just assume the compiler proved it was sound, and you don't have to think about it.
Instead, this mental model can provide an answer as to why the compiler gave you an error, after which you'll either think
Occasionally you'll also be surprised when things do compile, and you may have to expand your mental model (e.g. to accommodate reborrows). This is mostly true (in my experience) as you are still learning and building up your mental model, i.e., you'll be adjusting for failure modes long after you'll be adjusting for success modes.
I agree this isn't the most satisfying when you're trying to develop a complete understanding of the rules, or even appropriate when you're trying to suss out compiler bugs where it has potentially allowed an unsound program to compile. But I also feel such a complete understanding isn't actually possible (as there is no specification and even compiler experts are occasionally surprised / create bugs).
I have long wished for a "Rust lifetime book" to present an adequately complete, but not overwhelmingly complex, mental model. Alas, as far as I know, it does not exist.
Another approach is to build a mental model which is also sound, but potentially more general than the compiler / Rust itself. This is the goal of stacked borrows. It isn't concerned with static types per se, it tracks borrows themselves as a runtime analysis.
Let me take another shot at the last example under my looser mental take on stacked borrows, ala those diagrams I've presented before:
static I:i32 = 0; // ----- lifetime of I ------+------------------+
let mut rf; // | |
{ // | |
rf = &I; // (a) -- I used -+ ~ rf 1st used ~* |
// But the borrow isn't used after (a)... : |
let ii = 0; // (b) ----+-- lifetime of ii ---+ : |
// ii is about to get used | | : |
let rf_block = ⅈ // (c) ----+ This borrow gets | : |
// | used but not | : |
rf = rf_block; // (d) ----+ after (d) | : |
// ii is not longer used after here -----------------------+ : |
rf = &I; // new // (e) -------+ : |
} // | : |
rf; // (f) -------+ : |
// ~~~~~ rf no longer used ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~* |
// :
If we accept the fudging about rf
not being used until sometime after it's declared, it works (there are no crossed edges). But I admit, this take is still a mental model weaker than stacked borrows itself, and is still pretty much a static analysis. (Granted, the example is linear.)
A more formal look at this example with stacked borrows would instead actually write out the stacks of every memory location with the tags of the borrows, etc. My apologies, but I'm not going to do that here. But if you run Miri (under tools in the playground), that uses stacked borrows, and it accepts the program.
Both what I've presented here, and doing the formal analysis, are exercises in showing a lack of runtime memory unsafety, in contrast with an exercise in determining what static lifetime (variable type) the compiler inferred. I.e.,
- Useful for convincing ourselves it's ok Rust accepted some program
- Useful for demonstrating why it's right Rust rejected some program
- Useful for arguing that Rust rejected a program it could have accepted
- Not necessarily useful for figuring out what the compiler actually does or what a lifetime was inferred to be
As is probably clear if you have read this far, even though I have what I feel is a decent grasp on Rust lifetimes, I am still primarily exercising mental models that are more simplistic than the actual analysis the compiler (or Miri) perform. If you read the NLL RFC, I think you'll agree that it is quite low-level and challenging to perform in your head. And as I also said, I feel it is impractical to not have a mental model simpler than the compiler itself.
Therefore, I encourage to develop your own mental models that sufficiently explain Rust lifetimes most of the time, even if they are not technically speaking complete. And that includes continuing to ask questions like you have been in this thread!