Why are borrows of struct members allowed in &mut self, but not of self to immutable methods?
Asked Answered
S

2

8

If I have a struct that encapsulates two members, and updates one based on the other, that's fine as long as I do it this way:

struct A {
    value: i64
}

impl A {
    pub fn new() -> Self {
        A { value: 0 }
    }
    pub fn do_something(&mut self, other: &B) {
        self.value += other.value;
    }
    pub fn value(&self) -> i64 {
        self.value
    }
}

struct B {
    pub value: i64
}

struct State {
    a: A,
    b: B
}

impl State {
    pub fn new() -> Self {
        State {
            a: A::new(),
            b: B { value: 1 }
        }
    }
    pub fn do_stuff(&mut self) -> i64 {
        self.a.do_something(&self.b);
        self.a.value()
    }
    pub fn get_b(&self) -> &B {
        &self.b
    }
}

fn main() {
    let mut state = State::new();
    println!("{}", state.do_stuff());
}

That is, when I directly refer to self.b. But when I change do_stuff() to this:

pub fn do_stuff(&mut self) -> i64 {
    self.a.do_something(self.get_b());
    self.a.value()
}

The compiler complains: cannot borrow `*self` as immutable because `self.a` is also borrowed as mutable.

What if I need to do something more complex than just returning a member in order to get the argument for a.do_something()? Must I make a function that returns b by value and store it in a binding, then pass that binding to do_something()? What if b is complex?

More importantly to my understanding, what kind of memory-unsafety is the compiler saving me from here?

Spiritoso answered 18/3, 2017 at 19:50 Comment(0)
F
9

A key aspect of mutable references is that they are guaranteed to be the only way to access a particular value while they exist (unless they're reborrowed, which "disables" them temporarily).

When you write

self.a.do_something(&self.b);

the compiler is able to see that the borrow on self.a (which is taken implicitly to perform the method call) is distinct from the borrow on self.b, because it can reason about direct field accesses.

However, when you write

self.a.do_something(self.get_b());

then the compiler doesn't see a borrow on self.b, but rather a borrow on self. That's because lifetime parameters on method signatures cannot propagate such detailed information about borrows. Therefore, the compiler cannot guarantee that the value returned by self.get_b() doesn't give you access to self.a, which would create two references that can access self.a, one of them being mutable, which is illegal.

The reason field borrows don't propagate across functions is to simplify type checking and borrow checking (for machines and for humans). The principle is that the signature should be sufficient for performing those tasks: changing the implementation of a function should not cause errors in its callers.

What if I need to do something more complex than just returning a member in order to get the argument for a.do_something()?

I would move get_b from State to B and call get_b on self.b. This way, the compiler can see the distinct borrows on self.a and self.b and will accept the code.

self.a.do_something(self.b.get_b());
Foremost answered 18/3, 2017 at 20:26 Comment(1)
The strategy of moving get_b to B had not occured to me, but in this case it works extremely well since the purpose of State is to entirely encapsulate both A and B. Many thanks.Spiritoso
O
2

Yes, the compiler isolates functions for the purposes of the safety checks it makes. If it didn't, then every function would essentially have to be inlined everywhere. No one would appreciate this for at least two reasons:

  1. Compile times would go through the roof, and many opportunities for parallelization would have to be discarded.
  2. Changes to a function N calls away could affect the current function. See also Why are explicit lifetimes needed in Rust? which touches on the same concept.

what kind of memory-unsafety is the compiler saving me from here

None, really. In fact, it could be argued that it's creating false positives, as your example shows.

It's really more of a benefit for preserving programmer sanity.


The general advice that I give and follow when I encounter this problem is that the compiler is guiding you to discovering a new type in your existing code.

Your particular example is a bit too simplified for this to make sense, but if you had struct Foo(A, B, C) and found that a method on Foo needed A and B, that's often a good sign that there's a hidden type composed of A and B: struct Foo(Bar, C); struct Bar(A, B).

This isn't a silver bullet as you can end up with methods that need each pair of data, but in my experience it works the majority of the time.

Obsession answered 18/3, 2017 at 20:33 Comment(1)
This is interesting. The real world case this minimal example was extracted from was a struct containing the all the game-relevant state of a Pong clone, and A and B were the ball and paddle. In this case, a type PhysicsState could be extracted from the general GameState. Thank you for that insight.Spiritoso

© 2022 - 2024 — McMap. All rights reserved.