9) downgrading mut refs to shared refs is safe
Misconception Corollaries
- re-borrowing a reference ends its lifetime and starts a new one
You can pass a mut ref to a function expecting a shared ref because Rust will implicitly re-borrow the mut ref as immutable:
fn takes_shared_ref(n: &i32) {}
fn main() {
let mut a = 10;
takes_shared_ref(&mut a); // ✅
takes_shared_ref(&*(&mut a)); // above line desugared
}
Intuitively this makes sense, since there's no harm in re-borrowing a mut ref as immutable, right? Surprisingly no, as the program below does not compile:
fn main() {
let mut a = 10;
let b: &i32 = &*(&mut a); // re-borrowed as immutable
let c: &i32 = &a;
dbg!(b, c); // ❌
}
Throws this error:
error[E0502]: cannot borrow `a` as immutable because it is also borrowed as mutable
--> src/main.rs:4:19
|
3 | let b: &i32 = &*(&mut a);
| -------- mutable borrow occurs here
4 | let c: &i32 = &a;
| ^^ immutable borrow occurs here
5 | dbg!(b, c);
| - mutable borrow later used here
A mutable borrow does occur, but it's immediately and unconditionally re-borrowed as immutable and then dropped. Why is Rust treating the immutable re-borrow as if it still has the mut ref's exclusive lifetime? While there's no issue in the particular example above, allowing the ability to downgrade mut refs to shared refs does indeed introduce potential memory safety issues:
use std::sync::Mutex;
struct Struct {
mutex: Mutex<String>
}
impl Struct {
// downgrades mut self to shared str
fn get_string(&mut self) -> &str {
self.mutex.get_mut().unwrap()
}
fn mutate_string(&self) {
// if Rust allowed downgrading mut refs to shared refs
// then the following line would invalidate any shared
// refs returned from the get_string method
*self.mutex.lock().unwrap() = "surprise!".to_owned();
}
}
fn main() {
let mut s = Struct {
mutex: Mutex::new("string".to_owned())
};
let str_ref = s.get_string(); // mut ref downgraded to shared ref
s.mutate_string(); // str_ref invalidated, now a dangling pointer
dbg!(str_ref); // ❌ - as expected!
}
The point here is that when you re-borrow a mut ref as a shared ref you don't get that shared ref without a big gotcha: it extends the mut ref's lifetime for the duration of the re-borrow even if the mut ref itself is dropped. Using the re-borrowed shared ref is very difficult because it's immutable but it can't overlap with any other shared refs. The re-borrowed shared ref has all the cons of a mut ref and all the cons of a shared ref and has the pros of neither. I believe re-borrowing a mut ref as a shared ref should be considered a Rust anti-pattern. Being aware of this anti-pattern is important so that you can easily spot it when you see code like this:
// downgrades mut T to shared T
fn some_function<T>(some_arg: &mut T) -> &T;
struct Struct;
impl Struct {
// downgrades mut self to shared self
fn some_method(&mut self) -> &Self;
// downgrades mut self to shared T
fn other_method(&mut self) -> &T;
}
Even if you avoid re-borrows in function and method signatures Rust still does automatic implicit re-borrows so it's easy to bump into this problem without realizing it like so:
use std::collections::HashMap;
type PlayerID = i32;
#[derive(Debug, Default)]
struct Player {
score: i32,
}
fn start_game(player_a: PlayerID, player_b: PlayerID, server: &mut HashMap<PlayerID, Player>) {
// get players from server or create & insert new players if they don't yet exist
let player_a: &Player = server.entry(player_a).or_default();
let player_b: &Player = server.entry(player_b).or_default();
// do something with players
dbg!(player_a, player_b); // ❌
}
The above fails to compile. or_default() returns a &mut Player which we're implicitly re-borrowing as &Player because of our explicit type annotations. To do what we want we have to:
use std::collections::HashMap;
type PlayerID = i32;
#[derive(Debug, Default)]
struct Player {
score: i32,
}
fn start_game(player_a: PlayerID, player_b: PlayerID, server: &mut HashMap<PlayerID, Player>) {
// drop the returned mut Player refs since we can't use them together anyway
server.entry(player_a).or_default();
server.entry(player_b).or_default();
// fetch the players again, getting them immutably this time, without any implicit re-borrows
let player_a = server.get(&player_a);
let player_b = server.get(&player_b);
// do something with players
dbg!(player_a, player_b); // ✅
}
Kinda awkward and clunky but this is the sacrifice we make at the Altar of Memory Safety.
Key Takeaways
- try not to re-borrow mut refs as shared refs, or you're gonna have a bad time
- re-borrowing a mut ref doesn't end its lifetime, even if the ref is dropped