Tayliator
Tayliator

Reputation: 43

Why this immutable ref field hold a mutable borrow

With the following code, I was suprised by the borrow complaint

#[derive(Debug)]
struct ExternalStruct {
    some_thing: i32,
}

impl ExternalStruct {
    fn non_mutable_read(&self) {
        println!("{}", self.some_thing);
    }

    fn create(&mut self) -> MyStruct {
        self.some_thing += 1;
        MyStruct{external_value: self}
    }
}

#[derive(Debug)]
struct MyStruct<'a> {
    external_value: &'a ExternalStruct,
}


fn main() {
    let mut ext = ExternalStruct { some_thing: 18 };
    let my = ext.create();
    ext.non_mutable_read();
    println!("{:?}", my);
}

Error

error[E0502]: cannot borrow `ext` as immutable because it is also borrowed as mutable
  --> src\main.rs:26:5
   |
25 |     let my = ext.create();
   |              --- mutable borrow occurs here
26 |     ext.non_mutable_read();
   |     ^^^ immutable borrow occurs here
27 |     println!("{:?}", my);
   |                      -- mutable borrow later used here

I would understand this error if MyStruct would hold a &mut of ExternalStruct. But since it holds an immutable I misunderstand why the borrow fail.

For me the mutable borrow occured inside create and finish with the end of create (mutable borrow of self). That is true if the created object didn't hold a ref of self. And with the Mystruct objet I thought it started an immutable borrow which would be compatible with a second immutable borrow of non_mutable_read.

Upvotes: 0

Views: 59

Answers (1)

Chayim Friedman
Chayim Friedman

Reputation: 71485

From the excellent Common Rust Lifetime Misconceptions:

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

Upvotes: 3

Related Questions