Mutable borrows working when not expected

Maybe it's late. I was just revisiting a piece of code I wrote a while ago and I can't explain to myself why it works. It seems to be breaking two rules that I thought I understood about Rust:

let mut a = "shebot".to_string();
let pa = &mut a;        // mutable borrow of a
pa.push_str("bot");     // mutate through mutable borrow
a.push_str("goo");      // mutate through owner.. hold it, I thought owner should be "off limits" when a mutable borrow is in scope??  
let ba = &mut a;        // a second mutable borrow to a.. wth? I thought another rule was that you can't have more than one mutable borrow in a scope?
ba.push_str("foo");     // mutate a through second mutable borrow.
println!("After all that, a is {}", a);

My expectations are listed in the comments above. Basically my understanding was that you can't have more than one mutable borrow to the same value in the same scope, and that if you do have a mutable borrow, you can't change the value through the owner, both things the above code appears to do..

A better way of framing it is: you can't interleave exclusive access to a value. Be it creating a &mut _ or dereferencing a &mut _. The code snippet doesn't interleave exclusive access, so it's fine.

In the past, borrows did follow scopes, but that's far too limiting in many cases, so the borrowing model was updated to more precisely track borrows. This new model is called non-lexical lifetimes, or NLL.

1 Like

Rustc effectively transforms your code to:

let mut a = "shebot".to_string();
let pa = &mut a;
let ba = &mut a;
println!("After all that, a is {}", a);

It's able to do this because &mut doesn't run code when dropped, so dropping it can occur anywhere within the scope without changing behaviour. If instead of &mut you had used a different type that runs code when you drop it (it implements the Drop trait), this would not compile.

1 Like

Well that helps explain it.
I can't decide if I like this change or not. I mean, at least before the rules were more or less clear.

What are the rules now? That you might sometimes run into these kinds of borrow errors, or you may not? Ugh.

The old scope-based borrow checker rules weren't really as simple and clear as you imagine them to be. Implicit reborrowing, anonymous references, lifetime subtyping and temporary lifetime extension all predate NLL and blur the lines between the scope-based model and reality.

There is more than one way to think about complexity. People who are new to Rust often mistake what the borrow checker does for some version of control flow / escape analysis (probably because that's what other languages do). Before NLL, it was quite common for such a person to get a lifetime error about a borrow lasting after its last use, try to fix it by inserting drops – exactly as in @Kestrer's example, which still doesn't compile under scope-based borrows – and then become even more confused when it didn't work. Since NLL, those people simply won't encounter an error in those cases, and when they do find errors that conflict with their mental model, the differences tend to be more illuminating because they more often touch on fundamentally incorrect ideas about aliasing and memory, rather than coming down to how the compiler analyzes a certain bit of code.

I can definitely sympathize with being uncomfortable with adding complexity to the language. But look at it this way: NLL actually simplified the language by narrowing the gap between "what the borrow checker allows" and "what the abstract semantics allow". You don't have to dedicate as much brainpower to borrow checker trivia and workarounds for making technically correct code provably correct, because they are more often the same thing. Polonius, the next-gen borrow checker, just simplifies it further by erasing some of those stubborn holdouts that the current checker still incorrectly rejects.


See the 1.31 release blog post:

Or, if you want to go deeper, the RFC: 2094-nll - The Rust RFC Book

1 Like