The Rustonomicon , Lifetimes clarification about the scope/lifetime created after using let statement

let x = 0;
let z;
let y = &x;
z = y;
'a: {
    let x: i32 = 0;
    'b: {
        let z: &'b i32;
        'c: {
            // Must use 'b here because the reference to x is
            // being passed to the scope 'b.
            let y: &'b i32 = &'b x;
            z = y;
        }
    }
}

In the above code the lifetime 'b created after using let to make x ,ends after the last reference to x has been used.

In this case

let mut data = vec![1, 2, 3];
let x = &data[0];
data.push(4);
println!("{}", x);
'a: {
    let mut data: Vec<i32> = vec![1, 2, 3];
    'b: {
        // 'b is as big as we need this borrow to be
        // (just need to get to `println!`)
        let x: &'b i32 = Index::index::<'b>(&'b data, 0);
        'c: {
            // Temporary scope because we don't need the
            // &mut to last any longer.
            Vec::push(&'c mut data, 4);
        }
        println!("{}", x);
    }
}

The scope created after using x ends before the last reference to x is used .
Does the rust compiler actually keep track of scopes after let statements at the compiler level.
Where is this scope supposed to end?
I know that the 'desugar' he has done is only for explaining stuff but why did author end the lifetime 'c early in previous example?
I am completely unable to figure this one out.

The compiler definitely does track these lifetime regions, but they can't always be "desugared" into scopes like this. Non-lexical lifetimes can split between branches and such as well.

Maybe this chapter of the dev guide will help you?

5 Likes

The main interaction of inferred function body lifetimes and lexical blocks these days is that variables go out of scope at the end of their lexical block. It's popular to try and illustrate lifetimes with labeled blocks, but that's not how they actually work in the compiler (i.e. in the language).

Instead, the uses of reference and other lifetime-carrying things indicates where those lifetimes need to be valid (i.e. what the lifetimes are), and enables tracking where values are borrowed. Then every use of values are checked to make sure the use is compatible with how they are (or aren't) borrowed.

Walking through something closer to what the compiler actually does tends to be long and tedious, but I think that's what you're asking for, so in the next section I do so.


This is actually not even accurate, at least they way I read it:

'a: {
    let x: i32 = 0;
    'b: {
        let z: &'b i32;
        'c: {
            // Must use 'b here because the reference to x is
            // being passed to the scope 'b.
            let y: &'b i32 = &'b x;
            z = y;
        }
    }
}

Let me rewrite it in an attempt to make things less confusing, by giving lifetimes and blocks distinct names; then we'll walk through it.

'a: {
    let x: i32 = 0;
    'b: {
        let z: &'z i32;
        'c: {
            let y: &'y i32 = &'y0 x; // C1
            z = y;                   // C2
        }
    }
}

Despite the syntax of block labels, blocks themselves don't have lifetimes -- the compiler generates no lifetime constraints based on blocks. However, it does check that any variables going out of scope at the end of each block don't conflict with existing borrows.

When references in particular go out of scope, the reference itself can't be borrowed (like a reference to a reference), but it doesn't count as a use of what they point to and doesn't count as a use of the lifetime of the reference (i.e. the lifetime may already be invalid at that point [1]). In other words, the compiler understands that a reference going out of scope doesn't read or modify the data it points to.

The constraints in the example, due to the assignments, are that 'y0: 'y and that 'y: 'z. That means for example, that where ever 'z must be valid, 'y must be valid too.

'y (and thus 'y0) must be valid on C1 and C2, because that's where 'y is used. 'z (and thus 'y and 'y0) must be valid on C2, because that's where it is used. None of them are used anywhere else. So none of these lifetimes need to be valid outside of the 'c block.

At the end of block 'c, y goes out of scope. Nothing is borrowing y itself,[2] so this is fine. At the end of block 'b, z goes out of scope, and again nothing is borrowing z itself so this is fine. Finally at the end of block 'a, x goes out of scope. x was borrowed for 'y0, but nothing caused 'y0 to extend beyond C2, so it's not borrowed anymore. Therefore there's no conflict and the example compiles.

To make things simpler, you could imagine all of 'y0, 'y, and 'z being the same lifetime. But there's no real connection to block 'b, despite where z was defined. You can define it in a block wrapping 'a, say. The analysis would go the same way, and that compiles fine. If we used the convention that the reference used, it would imply z had a lifetime longer than x was around, which would be UB, which can't be true.


/* L1 */    'a: {
/* L2 */        let mut data: Vec<i32> = vec![1, 2, 3];
/* L3 */        'b: {
/* L4 */            let x: & /* 'x */ i32 = Index::index(&/* 'x0 */ data, 0);
/* L5 */            'c: {
/* L6 */                Vec::push(&/* 'p */ mut data, 4);
/* L7 */            }
/* L8 */            println!("{}", x);
/* L9 */        }
/* LA */    }

In this example, 'x and thus 'x0 must be valid for L4..=L8. But the use of data on L6[3] is incompatible with data being borrowed at all, so the use on L6 is in conflict with the borrow on L4 (which gets used on L8). The lifetime 'p is irrelevant -- no lifetime could make this okay.

The lexical blocks in this example happen to pretty much line up with the lifetimes in this case, unlike the compiling version(s) above -- and that's why it's popular to use blocks to try and convey the general idea, even though the blocks themselves aren't determining the lifetimes.

The main lifetime-related consideration in this example[4] is that data is dropped at the end of block 'a, which counts as an exclusive use of data.


  1. a non-lexical lifetime, if you will ↩︎

  2. there is no _: &&i32 = &y ↩︎

  3. taking an exclusive reference ↩︎

  4. (where the blocks actually do matter) ↩︎

4 Likes

So without any simplification.
1)Rust compiler goes throw code line by line from top to bottom and makes constraints on lifetimes of variables.
2)Then rust compiler checks if any two constraints contradict each other by either
{i) Having a mutable reference and any other type of reference at same time
ii)or there being a dangling reference.
}
If they don't contradict the program passes.
Is there a link where I can learn how rust compiler actually does this process in practice.
Maybe some sort of flowchart.

The NLL RFC is very close and the best non-code guide available probably. (Maybe there's something in the compiler dev guide.)

https://rust-lang.github.io/rfcs/2094-nll.html

The problem case #3 parts were dropped for being too inefficient to perform, but it's still planned to support that some day.

1 Like