Rust is a cool language. But a small misconception can happen due a misunderstanding of borrowing and scoping rules for the language. Let me illustrate.
For those unfamiliar, a big feature of Rust is its borrowing and ownership rules. The rules are quite elegant, and are enforced at compile-time with the compiler’s borrow checker to ensure memory safety. I will summarise the rules here briefly:
Rule 1, Ownership:
One and only one owner can exist for any data that is allocated.
Assignment ‘=’ and move
moves ownership of the data unless the datatype implements the Copy
trait, but that’s not really relevant for the puzzle later on.
|
|
Rule 2, Multiple immutable references:
Multiple immutable references can exist.
|
|
Rule 3, One mutable reference:
Only one mutable reference can exist at a time.
This prevents a ‘multiple writer’ problem.
|
|
Rule 4, (Rule 2) XOR (Rule 3):
i.e. shared-xor-mutable.
Only 1 mutable reference can exist, or
Only N immutable references can exist.
|
|
Rule 5, Every reference in Rust has a lifetime:
The lifetime is the scope for which that reference is valid. References must not live longer than the resource they refer to.
|
|
These rules help ensure that at compile time, potential data races, dangling references and use-after-free never happen. Awesome.
Now that we understand the basic rules, let’s try this puzzle out:
A simple(?) borrow checking puzzle
|
|
What will be the value of x?
A: 2
B: Compile error
C: Panics
Answer: x will successfully increment to 2!
Huh?
I hope that the puzzle felt unintuitive to you.
If you don’t believe the output, run it on the rust playground.
You might not believe it, but this is perfectly in line with the borrow-checker rules.
A closer look
We know via rule 4 we can either have one mutable reference XOR multiple immutable references.
y
is a mutable reference to x
. So, according to rule 4, we cannot have any more immutable references.
Yet, we can still add let z = &x
and it compiles.
Why is that the case? Let’s create a slightly different version of our program to see what’s going on.
|
|
In this case, the compiler will complain and give the error we expect:
|
|
Why is this the case?
Lifetimes
Rust docs tells us that the borrow check happens in Mid-Level Intermediate Representation (MIR) of the code. MIR is a simplified representation of Rust in middle of the compile process to use for safety checks.
We must therefore, gaze into the abyss (MIR files) to gain insight.
To simplify things, let’s say that in a given scope, the shared-xor-mutable rule must be adhered to.
The MIR of both programs will give us information about the lifetimes of each variable.
We can do this by compiling our code with the nightly compiler:
$ rustc +nightly -Zdump-mir=all main.rs
This generates a bunch of MIR files to let us see the program in the compile process. One artefact that MIR gives us is the lexical scope of the program.
Funnily, the lexical scope generated by the MIR is the same for both programs:
|
|
So, this does not tell us anything useful.
In fact, it seems to suggest that z
bleeds into y
’s scope which violates the shared-xor-mutable rule!
Wait, what is _1
, _2
, _3
? We can think of these as handles to the actual data.
The MIR tells us much more regarding this. The following block of operations generated by rust allows us to attach meaning to the lifetimes of the variables. For the version that compiles:
|
|
For the version that fails to compile:
|
|
Damn. Basically the same. Even for the compilable version, the lifetimes of x,y,z dont make sense!
The life of mutable reference y
overlaps with z
, right?
Not really. This is because of non-lexical-lifetimes (NLL).
Non-Lexical-Lifetimes
NLL: The lifetime of a reference is tied to the ‘control-flow-graph’ rather than its lexical scope. The end of a reference’s lifetime is tied to its last use.
The rust book summarises this nicely:
Note that a reference’s scope starts from where it is introduced and continues through the last time that reference is used.
|
|
This concept tends to be overlooked at when we have discussions about shared-xor-mutable (my personal observation). It is also a source of confusion to people trying to learn about the language.
Ok, so it seems that the MIR does not give us the full picture.
Shared-xor-mutable rule should be applied to the NLL scope rather than lexical scope.
So, going by the definition above, let’s manually annotate the MIR code with the NLL:
|
|
This makes more sense. If we use the same syntax as the lexical scope block, then the NLL would probably look like this:
|
|
But for the non-compileable version, we see that y
is used in the addition, which extends the scope of y.
|
|
This means that it translates to our original:
|
|
Which is buggy since z is a immutable borrow but in that scope, the scope of mutable borrow y exists. This violates the shared-xor-mutable rule.
Applying Scoping
Ok, maybe using MIR to explain is unnecessarily verbose.
Let’s make it more readable. Because of NLL, the scoping is as such:
|
|
For the failed section, we see clearly that z is stuck between y’s scope. The existence of z violates shared-xor-mutable, therefore it fails the borrow checker.
Now that we know that NLL is a thing, this resolves the main confusion about having compilable programs where it seems that the shared-xor-mutable constraints are violated.
More puzzles for you
To end off, here are some extra puzzles for you to test your understanding of NLL and shared-xor-mutable. Disclaimer: Some of these are generated by LLMs (because I have better things to do) but their answers were tested and reasoned with the author’s human brain and rust compiler.
Enjoy!
You have come to the end of the puzzles. Hope you enjoyed your stay!