Don't understand why mutable and immutable references can't be simultaneous - w/ examples

So, I'm trying to understand the logic of not allowing immutable refs at the same time as mutable refs.

Prevents:
An immutable read that a mutation breaks. Vec.pop() on Vec.last()
Collateral:

  • Unbreakable reads, or reads that are not yet called.
    • A model with multiple nodes, each node referencing other nodes. A caller with references to all nodes. Rc shared ownership. The model cannot mutate itself because the caller has references.
    • Note: no distinction made between external reference and internal reference (aka, passive vs active reads?). Here, the caller only has node references, which because of Rc won't die. Thus there is no way that mutating the model could break the node reference, but Rust treats it so. Vec.last() produces a reference internal to model and is thus breakable.
    • Workaround: use RefCell. Essentially divides "passive vs active" as "available vs borrowed". Caller only borrows target node.
      • Adds a bunch of RefCell overhead.
      • No longer have compile time information on if there are multiple mutable borrows at the same time. All has to be logic-ed out from a runtime perspective, even though allowing multiple mutable borrows wasn't the goal in the first place.
      • Target node can mutate all other nodes, however, none of those other nodes can acquire a reference to target node in the process. This breaks things at runtime.
        • Doesn't actually indicate a problem? Obviously, other nodes acquiring a mutable reference is an issue. However, say they acquire an immutable reference. Once again, there is a problem of distinguishing "passive vs active". other_node has a reference to target_node, but not to any internal target_node things. If target_node mutates something, other_node has no issue, but Rust says it does.
        • An example of where this might happen: caller calls target node. Target node needs to locate a sibling that meets some criteria. Target node calls on parent to search its children. Parent has no way to exclude target node from search, and so acquires an immutable reference to target node.
          • Further solutions: attach some kind of id to target node, which parent node can use to exclude from search. Or have node have references to all siblings, which are maintained by the parent. Either way, a lot of overhead for a non-issue.
        • Can rectify with a deeper layer of RefCell. Make all node methods "immutable", and only need Rc pointers for calling methods on nodes. Nodes internally wrap data in RefCell, and handle all the borrowing for reading and writing. This is what slint's VecModel essentially does.
          • Lost external compile time information. If all public methods are "immutable", and there is some kind of public read that returns an internal reference, then in a single thread you could mix an active read and write. If you have multiple threads, you could wrap in Arc thinking that it's fine as all methods are immutable, and break something. This can only be warned against via documentation.
          • Maintenance-wise, the lack of compile time linting on the internal node methods goes deeper.
          • For my use case, this seems like the best solution.

It seems like things would be a lot simpler if there was a distinction between active and passive immutable references. External vs internal? Basically if ownership applied to references.
Say that obj.read() returns a reference to a value owned by obj. Then obj.write() is called. As there exists an immutable reference to a value owned by obj, this is not allowed.
Now say that you have &obj, or an obj.read() that returns a value not owned by obj. obj.write() being called goes perfectly fine.
I think this could still be done with two keywords: &mut could also apply to immutable references that are breakable, where &muts can't coexist. But I can't just decide on this convention, as Rust would still block coexisting &mut and &.

I could also think of a couple useful variations on RefCell:
RefCell that still does compile-time checks for overlapping mutable references, but not immutables.
Could take this further, and implement the runtime checking for borrow_mut as I envisioned for &mut above. Would have to somehow mask this from Rust runtime checks though...
Combined Rc/RefCell, where you can get a Rc/RefCell that is restricted to only allow borrowing immutables. Kinda along the lines of strong-weak Rcs, except now it's about mutability access.

I'm a beginner to Rust. Am I misunderstanding something?

I'm currently trying to write a graphic program. The model is a tree of nodes. The updator has references to all the nodes, but no knowledge of the structure of the nodes. It can read a node to update the ui. It can update a node, and the node may need to propagate the change to other attached nodes. I'm in RefCell hell right now, so if you have any suggestions as to how you would tackle these shared references, please let me know.

This sounds like Mistake #3: Start by implementing a graph based algorithm from How not to learn Rust.

Rust is deliberately designed so that "shared access" XOR "mutation" is the happy path (for very good reasons that we don't really need to get into right now), but the way GUI programs are typically designed (tree of objects where components use callbacks to notify other components of events) requires "shared access" AND "mutation".

Instead of the "web of references and callbacks" architecture you'd typically use when writing code in C# or Python, you might want to consider a different approach altogether. For example, the "model, view, update" architecture from Elm works pretty well in Rust and has been adopted by several UI projects (e.g. yew or iced) with a lot of success.

It's also a good idea to ask yourself whether l creating a GUI program is the best place to start with Rust. Are there other applications you might want to write which have a simpler ownership story?

I'm not trying to be too dismissive here, but sometimes it's best to become more familiar with a language before proposing something that would fundamentally change the way it works.

There are often very good reasons for the way things are done, and a change that you see as simple or more intuitive might actually go against the language's values or be logically unsound.

You might want to check out the ghost_cell crate for a compile-time checked RefCell, although I don't think it would help in your specific situation without adding a lot of cognitive overhead.

6 Likes

You may find it curious that this compiles:

struct MyStruct<'a> {
    external_value: &'a str,
    internal_value: String,
}

impl<'a> MyStruct<'a> {
    fn new(e: &'a str, int: String) -> Self {
        Self { external_value: e, internal_value: int }
    }
    
    fn get_external(&self) -> &'a str {
        self.external_value
    }
    
    fn set_internal(&mut self, value: String) {
        self.internal_value = value;
    }
}

fn main() {
    let external_owner = "Hi!".to_string();
    
    let mut my_struct = MyStruct::new(
        &external_owner,
        "Foo".to_string(),
    );
    
    let a = my_struct.get_external();
    my_struct.set_internal("Bar".to_string());
    println!("{}", a);
}

This works because get_external returns &'a str as opposed to &str.

1 Like

Especially when starting Rust, it's useful to consider references (&, &mut) as short-lived borrows rather than long-lived pointers to keep around.

Additionally, a better (and more accurate) portrayal than "mutable references" and "immutable references" is "exclusive references" and "shared references". Shared mutability (through a &) is possible in Rust via constructs like RefCell; we call it "interior mutability". But a &mut is always exclusive. (It would have been better named &uniq or the like.)

So a new type of shared borrow wouldn't really help where &mut is involved; exclusivity still needs to be guaranteed. The two types of references can't be simultaneous as that wouldn't be exclusive.

If I had to pick a single probable misunderstanding, it would be "&mut guarantees exclusivity."

The exclusivity of &mut is not just about "breaking (other) references", by the way. It is also used to prevent data races with e.g. Mutex, even when there's no possibility of something like a use-after-free. And for things like copy_from_slice to assume non-overlapping memory, even on a single thread. Beyond this, its non-aliasing can also be taken advantage of for optimizations more generally (e.g. by LLVM). And on an entirely different level, it's simply a core property of Rust.

Having a &obj still let's you safely get a &obj.field in a variety of ways. Or to use your example... having a &'a Vec<T> allows calling .last() at any time and storing the return for 'a, so nothing that could .pop() can be allowed in the region 'a.

If the idea was you couldn't do safely do anything with external references, well... pointers you can't safely read through are *const T... but then it's up to you to uphold Rust's guarantees, or to throw out the advantages and read through the pointers directly; either way you'll lose the advantage of knowing it points to something valid and will need lots of unsafe. (Not recommended.)

I don't think there's a feasible way to get the compile-time guaranteed "points at something valid" quality without the other restrictions.


As for breaking something containing a RefCell with Arc, that's what (the lack of) Sync prevents.

6 Likes

&mut is exclusive by definition. It's not an inconvenience or weakness to be eliminated. It's a property you can take advantage of in API contract.

Sort of like unsigned numbers aren't just a worse version of signed numbers, but a deliberate "this can't be negative" design. The same way &mut is "this is the only active reference to this object and you can rely on it" as a feature. IMHO it's poorly named, because things can be mutated via shared references too (interior mutability).

5 Likes

Similar example to @alice's, but perhaps more directly reflecting what you described. Does this do what you want?

struct Object<'a>(&'a i32);

impl<'a> Object<'a> {
    fn new(value: &'a i32) -> Self {
        Object(value)
    }

    fn read(&self) -> &'a i32 {
        self.0
    }

    fn write(&mut self, value: &'a i32) {
        self.0 = value;
    }
}

fn main() {
    let number_1 = 123;
    let number_2 = 456;
    let mut obj = Object::new(&number_1);
    let output = obj.read();
    obj.write(&number_2);
    println!("In obj: {}", obj.read());
    println!("Previously read: {}", output);
}
In obj: 456
Previously read: 123

Here's an excellent blog post about just that:

And it's not only easier for humans to deal with code under the separation, but it lets the computer be smarter about it too!

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.