Why can't you use a mutably borrowed value in-between mutable changes?

That's some terrible design. Please don't expect that to be supported. Since this is a toy example, I can't tell why you would need this in real life, but in general shared mutability is confusing even if it can be made technically sound and memory-safe.

I can't tell you how many times I debugged Python code for myself and friends where the error basically boiled down to mutating a list that was referenced from multiple places unexpectedly. It is a horrible experience and every experienced programmer suggests others to avoid it even in GC languages where it is perfectly "safe".

That's exactly what you should do in such a case, or even better, re-organize the code so you don't need it. By the way, in what sense do you think it is an overhead? Surely if you are writing to strings and potentially causing re-allocations, checking a single boolean shouldn't be a bottleneck…?

I'm aware of reborrowing, and it is a completely different situation. When you re-borrow from a mutable ref, the compiler can reason locally because the lifetime of the re-borrow will be a strict subregion of the lifetime for which the mutable reference is valid.

Think about it like this: &mut gives away unique access to the value, so whoever holds the &mut can do whatever they please with the pointed value in terms of reading and/or writing it. However, the access must remain unique, so you can't give away shared references of an arbitrary lifetime to it. This is the key difference: if you were allowed to create a &mut and then an independent immutable reference, then the lifetime of the two wouldn't be intertwined in this way, and it could happen that a supposedly immutable reference observes an unexpected change in the underlying value. When you re-borrow a & from a &mut, this can't happen, because the compiler knows the latter immutable ref never outlives the mutable one, and the mutable one will be "freezed" temporarily until the reborrow expires.

For example, if you uncomment the marked line in the snippet below, the compiler will complain:

fn main() {
    let mut x: i32 = 42;
    let x_ref = &mut x;
    println!("x_ref = {}", x_ref);
    let x_reborrow = &*x_ref;
    println!("x_reborrow = {}", x_reborrow);
    // uncomment the line below to fail borrowck
    // *x_ref = 43;
    println!("x_reborrow again = {}", x_reborrow);
}
2 Likes

I would like to throw in that being too lax about rules (particularly if they rely on implementation of certain functions) also might lead to SemVer hazards. I.e. if the program only compiles because a particular implementation is sound, then changing the implementation (and not the interface) could lead to a compiler error.

I noticed this problem, for example, when the Sendness of futures depends on function implementation.

(But not sure when that really applies. It was more meant as a general comment regarding lax rules.)

As long as there is a mutable (better terminology: exclusive) ref holder, the original owner cannot give an immutable (terminology: shared) ref to anyone else.

This is an excellent point, the ability to reason locally. I'll add that is equally it maybe even more important for humans to be able to reason locally about code.

NLL is a huge win, but it was tricky and took time, because it was not so easy to come up with rules that humans would be able to understand and work with, precisely because it makes it far harder to reason about when a given borrow stopped.

2 Likes

Actually I would say even stronger: that scenario is exactly why we have all these complicated rules.

And that's exactly and precisely why it's not allowed.

It's hard for you to recreate reference because if you have no idea if it points to text1 or text2. But compiler would face the same issue. Another developer who would read your program would face the same issue.

Everyone would have the same problem.

Don't do it then. Try to find some way which would help you avoid the issue. If not — then pay the price and introduce boolean (if that's just for debugging then there are always dbg!, which often helps you to avoid making another reference to mutable variable).

“One-writer xor many writers” model was invented and reinvented again and again. Because it's easy to follow model (also mathematically proven, but that happened decades after it's invention).

Even before computers were invented people followed that model.

Documents which are edited together usually have “master copy” (which only one person can access) and then (after round is editing is done) it can be copied and send to other people for review.

RW-locks predate Rust, too. Perl's pumpkin. Developers of C tried to do something like that (but ultimately failed).

That is why Rust works like it works. Deep in it's heart Rust is a functional language with a twist. The twist being: unlike “fully pure” functional language Rust makes it possible to have one (and no more than one) mutator.

If that rule is followed then there are no ambiguity and both writer of the code as well as other developers as well as compiler can easily predict when variable can not be changed (if there are active mutator then it can change the variable, but nothing else can, if there are no mutator then nothing can change variable at all).

Which is exteremely important for CPU and compiler both. And for developer, also (as you were repeatedly pointed out).

Now, you are correct that supporting that model is a bit annoying at times. And compiler, sometimes, can silently transform program behind your back to make code compile-able (e.g. it may shorten lifetime of mutable reference if noone uses it beyond certain point).

Now we can finally answer you initial question:

Yes, that's true but you either can easily manually reborrow, or, if there are mutable changes during mutable borrow lifetime — then it's forbidden and to make these forbidden all these lifetimes and borrow rules exist.

I rewrote your sample so it actually compiles. But more importantly so we can have a meaningful conversation about it: Rust Playground

You don't need to drop the reference(s). In fact, you can just read through them.

You're right, that is unnecessary. I am suspicious of how much overhead it actually would be in practice. Surely, a real application has more pressing issues than a boolean. Anyway, my rewrite moves the responsibility of that boolean to the type system. And FWIW, it compiles away to just a few mov instructions: Compiler Explorer

And here is the full output with all of the String growing and callers to the left and right methods: Compiler Explorer

That is not the way I read the code at all. It is reborrowing a unique reference. This is a lot different from the original problem statement, and a lot different from the interpretation quoted. The code is not attempting to borrow the original value while a unique borrow exists. It's borrowing a borrow. @H2CO3's response to this one is a must-read.

I'll quote the relevant point inline for clarity:

Local reasoning is the key concept. The compiler can locally reason about the lifetimes of references (input and output) used by the choose_shorter() function by its signature alone. It knows that the return value lives as long as both inputs. And it does not need to do any analysis of the function body to determine how the output relates to either input. The single lifetime annotate is enough. (Aside: can you imagine the compile times if full-program analysis were required to resolve lifetimes?)

All told, I believe you already fully have all of the individual components needed to solve the choose_shorter() puzzle. Reborrow the unique references when necessary. Which means keeping both unique borrows around. Which means storing them in something like an enum, and adding some useful impls. The overhead is reasonable (zero-cost abstractions are nice) and the code at the call-site is not much more awkward than the unworking code.

Even after all that, I don't think there is a world where I would want the original unworking code to compile. It would either become additional complexity in the borrow rules (and thereby more complexity for us humans to remember), or it would open the doors to problems like iterator invalidation in safe Rust. Both scenarios are intractable, IMHO.

edit: I also want to point out that I have no idea what the final comment in that code is trying to express. Why is it "ok" to read text1 and text2 after the first mutate, but not after the second mutate? That sounds like a direct contradiction.

1 Like

Here's a very simple implementation using an array.

    let mut pair = ["Hello".to_string(), "From kajacx".to_string()];
    let shorter = if pair[0].len() < pair[1].len() { 0 } else { 1 };

    // mutate shorter
    pair[shorter].push_str(" appended");

    // read both
    println!("text1: {}, text2: {}", pair[0], pair[1]);

    // mutate shorter again
    pair[shorter].push_str(" appended again");

    // read both
    println!("text1: {}, text2: {}", pair[0], pair[1]);

Yes, but moving two strings into an array isn't what OP requested. As I pointed out in my response:

This approach with arrays is very different.

I agree that there are several ways to get something similar to compile. What I'm really interested in providing, though, is some insight into why the original code should not work. But to also show a way to work around it, in case there are specific reasons the data model cannot be changed. (Which is often the case.)

Anyway, I think I've made my point but I'm happy to continue discussing the existing borrow rules and why they should not be changed as drastically as questioned. That said, I do think the original question has been adequately answered by now.

1 Like

Sorry for late response, I was busy this week.

I rewrote your sample so it actually compiles.

Interesting use of enums. I'm assuming Shorter::Left(left, _) | Shorter::Right(left, _) => left, will be optimized to always read the "left" value without checking which enum variant it is?

Anyway, from read all the post, I think there are 3 possible reasons why Rust doesn't allow borrowing a ref while another mutable ref exists:

  1. It could cause horrible things like pointers to invalid memory.
  2. It would be infeasibly difficult to implement and check at compile time.
  3. It is a terrible design and would lead to many "user errors".

Let's look at these one-by-one with this example: Rust Playground

1. It could cause horrible things like pointers to invalid memory.

I don't think this is the case. In "Example 1", a reference is obtained, then the value is mutated, and then the reference is used. That can obviously cause problems. If, on the other hand, you obtain a mutable ref, then you use an immutable ref to the same object, and then use the mutable ref, no such "horrible" problems should happen.

People write "it is necessary for type safety", "rust must do this to enable multithreading", "things would not work if mutable ref wasn't unique", but I haven't seen 1 single code example of how things would break it Rust allowed this. See Example 1 from the code to understand what kind of example I want to see.

Perhaps things like Cell could break it, because it allows modification behind a & reference. However, if Cell doesn't break it when the holder of the mutable ref reborrows that ref to someone, then it shouldn't break it when the owner of the original data borrows it as well (because after compilation it will be exactly the same instruction with exactly the same data).

2. It would be infeasibly difficult to implement and check at compile time.

I don't think it would be. As I said before, Rust already knows which reference points to which data, and that's why it disallows you to create more references from that data while a mutable ref exists on it. And Rust can do this (track references) just by reasoning locally (looking at method signatures) and without global analysis. Therefore, it could, at least in theory, use the same knowledge it already has to allow making read-only references while a mutable ref exists.

3. It is a terrible design and would lead to many "user errors".

I think this is the real reason. Of course my code was horrible design, the goal was to find out what Rust can and cannot do (and not to create a readable and maintainable code), and it succeeded at that. As I outline in the Example 3, I think the real reason is "user-level" errors (like an array index out of bounds) as opposed to "assembly-level" error, like pointer pointing to an invalid memory.

One comment caught my eye (I added the emphasis):

@H2CO3
I can't tell you how many times I debugged Python code for myself and friends where the error basically boiled down to mutating a list that was referenced from multiple places unexpectedly. It is a horrible experience and every experienced programmer suggests others to avoid it even in GC languages where it is perfectly "safe".

And I think that is the real reason. Another way to explain it would be "invariants". There are "Rust-level" invariants, that Rust will guarantee, like "a reference will always point to a valid memory", "a NonZeroI32 will never be zero", etc. I don't think this kind of "reading from a varaible that has a mutable ref on it" would break these invariants that Rust guarantees.

However, it could break "user-level" invariants, like "Array index will be in bounds", or "string will be at least 10 characters", etc. Because when modifying a structure, it is often necessary to temporarily break these invariants. And when such "temporary" break needs to happen over several method calls, then things could get really bad really quicky.

Disallowing making readonly refs does not solve this problem (you could, for example, re-borrow the mutable ref when in invalid state and still end up with an error), but at least it somewhat limits the possibility to create such error. At least that's how I understand it.

During the lifetime of mutable_ref, it does not necessarily point to the same object that my_struct.my_value refers to. The non-aliasing guarantee of &mut allows the compiler to make many optimizations that could violate this assumption.

For instance, given just this code,

    let mutable_ref = my_struct.get_value_mut();
    mutable_ref.push_str("Modify once again");

Suppose my_struct is, during some optimization phase, placed on the stack and not in registers. Naively you might assume the compiler has to generate code something like

load the address of my_struct into register $A
call get_value_mut (which replaces $A with the address of my_struct.my_value)
load the address and length of "Modify once again" into registers $B and $C
call push_str

But calling functions is expensive, so it will try to inline those, and they're easy to inline:

1) load the address of my_struct.my_value into $A
2) load the address and length of "Modify once again" into $B and $C
3) load the length and capacity fields of *$A into $D and $E
4) add $D to $C; if the sum is greater than $E,
     // let's skip the details of reallocation; it's not important here
5) load the pointer field of *$A and add it to $D, putting the result back in $A
6) call memcpy($A, $B, $C)

Now, the dependencies between those instructions are as follows (<- means "happens after")

6 <- 2, 4, 5
5 <- 1, 3
4 <- 2, 3
3 <- 1
2 -----------.
        \     \
1 --> 3 --> 4  \
  \     \    \  \ 
   \------> 5 ----> 6

For correct operation, the CPU is allowed to traverse this graph in any topological order it likes. So 1-2-3-4-5-6 is a possibility, but if it seems more parsimonious (for instance, due to register reassignment making it possible to save a register, or because certain instructions take longer than others) to go 2-1-3-5-4-6, or 1-3-5-2-4-6, the compiler may do that instead. In fact, the CPU itself may observe these dependencies and reorder the instructions even without the compiler's say-so. Modern CPUs can be very clever about pipeline reordering.

So now let's add my_struct.reborrow_value(); in between those two lines and see what happens. After inlining, the code may still look reasonable:

1) load the address of my_struct.my_value into $A
7) load the pointer and length fields of my_struct.my_value into $F and $G
8) call write(stdout, $F, $G)
2) load the address and length of "Modify once again" into $B and $C
3) load the length and capacity fields of *$A into $D and $E
4) add $D to $C; if the sum is greater than $E,
     // let's skip the details of reallocation; it's not important here
5) load the pointer field of *$A and add it to $D, putting the result back in $A
6) call memcpy($A, $B, $C)

But let's look at our dependency graph:

2 -----------.
        \     \
1 --> 3 --> 4  \
  \     \    \  \ 
   \------> 5 ----> 6

7 --> 8

Because we're using a &mut reference, which is not allowed to alias another reference, the part of the graph involved with mutable_ref is exactly the same. There are in fact no dependencies between instructions 1-6 and 7-8. This is in some sense the main point of &mut: it breaks false dependencies so instructions can be reordered to make the program run faster. But you lied to the compiler, and told it to break a true dependency. The borrow checker noticed, because that's its job, and stopped you. But what if the borrow checker didn't exist?

Technically we are already in UB-land so it's futile to keep thinking about this code as if it had any specific behavior. But imagine ordering these instructions as 1-7-2-3-4-5-6-8. Reallocation happens in step 5, but in step 8 the register $F still points to the old buffer. So by aliasing a &mut reference, we have, in fact, caused exactly the "horrible" problem you said could not happen!

It is not just "user-level" invariants that &mut protects; it is the illusion of sequential execution. When everything is immutable, instructions can happen in any order that makes them run fast. Optimizing compilers and modern CPUs love to do this. When you introduce mutable state, it becomes very important that instructions which are ordered relative to each other stay in order, while instructions that are unordered continue to run as fast as possible. &mut is a tool for the programmer to communicate their intent to not alias, which translates on a lower level to breaking data dependencies between instructions. It's not really about mutation, per se, which is why things like Cell allow mutation through a shared (&) reference. Interior mutability allows you to mutate things without claiming exclusivity (&mut).

8 Likes

That is factually incorrect. The Rust compiler optimizes based on the assumption that a mutable reference never aliases with another reference. This could alter the semantics of code even in cases when it doesn't lead to unsoundness.

Thank you for taking the time to explain the problem with an actual code example. I was a victim of compiler "optimizations" before, so I understand this is not something to underestimate.

1 Like

This is a technicality, but your "Example 1" is highlighting a shortcoming in Rust as the language currently stands. The specific issue is something Niko wrote about a while back: Blog post series: After NLL -- what's next for borrowing and lifetimes? -- And take note of the discussion that followed. There is more discussion in Partial borrowing (for fun and profit) · Issue #1215 · rust-lang/rfcs · GitHub which predates Niko's article.

The example only borrows disjoint fields of the struct. The issue is that the compiler does not do whole-program analysis to discover this fact. If it did, it would be able to deduce that mutating the my_ref field is safe while a shared borrow exists of the my_value field.

That's why, E.g., this works:

fn main() {
    let mut text = "Hello".to_string();
    let mut my_struct = MyStruct {
        my_value: "World".into(),
        my_ref: &mut text,
    };
    
    // Example 1: Use reference after mofification
    
    // get ref from my_struct
    let my_value = &my_struct.my_value;
    
    // modify my_struct
    my_struct.my_ref.push_str(" Some text");

    // use previously read reference
    println!("my_value: {}", my_value);
}

The get_value and push_ref methods are not borrowing distinct fields like this, they are borrowing the entire struct.

Only in very simple cases! The code above in main is a great example of a very simple case where the current borrow checker implementation can resolve the distinction. For an example of a more challenging case, we can peel apart your "Example 1". Starting with this method signature:

fn get_value(&self) -> &String

This can be desugared to:

fn get_value<'b>(self: &'b MyStruct<'a>) -> &'b String

Seeing only the method signature, we know that the whole struct is borrowed for lifetime 'b. Without analyzing the function body, we cannot tell if this borrow is too strict, or if there is an interpretation where we can relax the borrow to a smaller set of fields on the struct. (The compiler really only needs to care about what is borrowed on the return value -- At least in my understanding. Someone may correct me if I'm wrong. I don't know of anything in the language that says the compiler will never be able to reduce the scope of these kinds of borrows. Even if it requires additional syntax.)

Doing any analysis on the function body would be loads more complex than the simple local analysis that is done today. Imagine that this method calls other methods (which then call other methods, possibly across library boundaries), and conditions are involved where one branch borrows one field and the other branch borrows a different field! You can take this thought experiment to its final conclusion and realize that it would be very difficult to implement this kind of checking at compile time.

2 Likes

The example only borrows disjoint fields of the struct. The issue is that the compiler does not do whole-program analysis to discover this fact.

Yes, I was quite aware of this. And I'm fine with it, I'm not asking Rust to look into methods to discover this, as you have pointed out yourself:

Doing any analysis on the function body would be loads more complex than the simple local analysis that is done today. Imagine that this method calls other methods (which then call other methods, possibly across library boundaries), and conditions are involved where one branch borrows one field and the other branch borrows a different field!

So I want to be clear that I never asked if Rust could do this kind of "deep" analysis to discover where each ref points to exactly.

What I wanted to know is: why does Rust not allow to read from a value that has a mutable ref on it, using the same rules that it already uses to not allow mutating a value that has a & ref on it. So, if rust disallows this code from Example 1:

    // get ref from my_struct
    let my_value = my_struct.get_value();
    
    // modify my_struct
    my_struct.push_ref(" Some text");
    
    // use previously read reference
    println!("my_value: {}", my_value);
    // Oh no! my_struct was modified and my_value ref could have been invalidated

even though my_struct.push_ref is modifying a different field than my_struct.get_value returns, I would be just-as-ok with this not working:

    // create a mutable ref
    let mutable_ref = my_struct.get_value_mut();
    
    // use an immutable ref
    my_struct.reborrow_ref();
    // Oh no! cannot borrow `my_struct` as immutable because it is also borrowed as mutable

    // use the mutable ref again
    mutable_ref.push_str("Modify once again");

even though my_struct.reborrow_ref is reading from a different field than my_struct.get_value_mut is modifying.

This looks interesting:

fn get_value<'b>(self: &'b MyStruct<'a>) -> &'b String

but I didn't manage to get it to work. I guess in "real" code you would never need this and could always just throw away the old borrow and then borrow again, I was just curious why you couldn't read from a value that has a mutable ref on it and now I know it is because of the compiler optimizations.

Doing any analysis on the function body would be loads more complex than the simple local analysis that is done today

Not only that, but it would also make the function interface depend on the body, not only on the signature. This loss of locality has far-reaching negative consequences and as far as I remember, it's explicitly a design principle in Rust to avoid it.

5 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.