How compiler detects difference between different binding scopes at MIR level?

This code compiles:

fn main() {
    let b = true;
    loop {
        if b {
            let x = 1;
        }
    }
}

But this isn't:

fn main() {
    let b = true;
    let x;
    loop {
        if b {
            x = 1;
        }
    }
}

I see no difference in their MIR (I'm using --emit=MIR). How it works?

How would you get the compiler to emit MIR for the second code in the first place?

I added a mut and assumed that it doesn't change the MIR.

I see. Adding the mut to both for consistency gives me

// WARNING: This output format is intended for human consumers only
// and is subject to change without notice. Knock yourself out.
fn main() -> () {
    let mut _0: ();                      // return place in scope 0 at src/main.rs:1:11: 1:11
    let _1: bool;                        // in scope 0 at src/main.rs:2:9: 2:10
    scope 1 {
        debug b => _1;                   // in scope 1 at src/main.rs:2:9: 2:10
        let mut _2: i32;                 // in scope 1 at src/main.rs:5:17: 5:22
        scope 2 {
            debug x => _2;               // in scope 2 at src/main.rs:5:17: 5:22
        }
    }

    bb0: {
        _1 = const true;                 // scope 0 at src/main.rs:2:13: 2:17
        goto -> bb1;                     // scope 1 at src/main.rs:3:5: 7:6
    }

    bb1: {
        switchInt(_1) -> [0: bb1, otherwise: bb2]; // scope 1 at src/main.rs:4:12: 4:13
    }

    bb2: {
        _2 = const 1_i32;                // scope 1 at src/main.rs:5:25: 5:26
        goto -> bb1;                     // scope 1 at src/main.rs:4:9: 6:10
    }
}

and

// WARNING: This output format is intended for human consumers only
// and is subject to change without notice. Knock yourself out.
fn main() -> () {
    let mut _0: ();                      // return place in scope 0 at src/main.rs:1:11: 1:11
    let _1: bool;                        // in scope 0 at src/main.rs:2:9: 2:10
    scope 1 {
        debug b => _1;                   // in scope 1 at src/main.rs:2:9: 2:10
        let mut _2: i32;                 // in scope 1 at src/main.rs:3:9: 3:14
        scope 2 {
            debug x => _2;               // in scope 2 at src/main.rs:3:9: 3:14
        }
    }

    bb0: {
        _1 = const true;                 // scope 0 at src/main.rs:2:13: 2:17
        goto -> bb1;                     // scope 2 at src/main.rs:4:5: 8:6
    }

    bb1: {
        switchInt(_1) -> [0: bb1, otherwise: bb2]; // scope 2 at src/main.rs:5:12: 5:13
    }

    bb2: {
        _2 = const 1_i32;                // scope 2 at src/main.rs:6:13: 6:18
        goto -> bb1;                     // scope 2 at src/main.rs:5:9: 7:10
    }
}

Ignoring source location information there still is a difference, as far as I can tell:

fn main() -> () {
    let mut _0: ();                      // return place in scope 0
    let _1: bool;                        // in scope 0
    scope 1 {
        debug b => _1;                   // in scope 1
        let mut _2: i32;                 // in scope 1
        scope 2 {
            debug x => _2;               // in scope 2
        }
    }

    bb0: {
        _1 = const true;                 // scope 0
-       goto -> bb1;                     // scope 1
+       goto -> bb1;                     // scope 2
    }

    bb1: {
-       switchInt(_1) -> [0: bb1, otherwise: bb2]; // scope 1
+       switchInt(_1) -> [0: bb1, otherwise: bb2]; // scope 2
    }

    bb2: {
-       _2 = const 1_i32;                // scope 1
+       _2 = const 1_i32;                // scope 2
-       goto -> bb1;                     // scope 1
+       goto -> bb1;                     // scope 2
    }
}

The difference is in the “scope” information indicated as a comment-like notation in the human-readable mir, though I would assume that this information is not completely irrelevant for compilation. I’m not familiar enough with MIR to tell you why this information is in a comment-style; perhaps it’s only relevant for some checks during compilation but becomes meaningless for semantics once the code is accepted in the first place.

Also, compilation checks can of course happen before MIR is created. I don’t know whether or not that’s the case for the error in question.

For better understanding of the MIR: As far as my interpretation goes, the meaning of “scope 2” differs between the two programs

fn main() {
    let b = true; // scope 1 --------------+
    loop {                              // |
        if b {                          // |
            let mut x = 1; // scope 2 --+  |
        } // end of scope 2 ------------+  |
    }                                   // |
} // end of scope 1------------------------+
fn main() {
    let b = true; // scope 1 --+
    let x; // scope 2 --+      |
    loop {           // |      |
        if b {       // |      |
            x = 1;   // |      |
        }            // |      |
    }                // |      |
} // end of scope 2 ----+      |
  // end of scope 1 -----------+

So in one case, the loop happens all inside scope 2, whereas the other case has the loop happening in scope 1, and scope 2 is really short. The initialization of x in let mut x = 1 apparently itself is also considered not yet part of scope 2, but that doesn’t seem the most relevant thing, given that changing it to

fn main() {
    let b = true;
    loop {
        if b {
            let mut x;
            x = 1;
        }
    }
}

makes the line in mir that does the _2 = const 1_i32 operation be associated with scope 2 again, without changing much about the meaning of the program, really.

That scope is part of the SourceInfo struct. It makes me sad if compiler really decides per that information. I was expected to see StorageDead or Uninit statement or something similar. Is there any chance that these statements are not visible and just omitted in the MIR output, or there is actually no such statement in the real MIR?

Are you using an optimized build? IIRC the MIR doesn't bother including them for unoptimized builds.

No, I'm using debug build. In optimized build, I see some StorageLive statements, but no StorageDead. I guess those are optimized out. I want to see the MIR that is used by the borrow checker. Is there any option to enable showing StorageLive and StorageDead in debug mode, and is it equal to the input of the borrow checker?

There are some MIR optimizations enabled even in debug mode, you need to use -Zmir-opt-level=0 to disable these, e.g. https://rust.godbolt.org/z/sazbWxsnK.

1 Like

When working with MIR, the essential option is -Zdump-mir=all. It emits MIR at various stages of the compilation. Unlike -Zunpretty=mir, it also works when compilation eventually fails. MIR is saved saved into mir_dump directory. In this case, a MIR that would be the most relevant is the one in a file with nll.0.mir suffix.

The "cannot assign twice to immutable variable" error is emitted by report_illegal_reassignment method, which has only one use site. Before emitting the error, borrow checker consults the results of EverInitializedPlaces data flow to determine if the variable has ever been initialized before the assignment.

The results of EverInitializedPlaces data flow can be saved as a graphviz file with -Zdump-mir-dataflow option (the file with ever_init.borrowck..dot suffix).

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.