Mut => Confusion Between Classic Mutability and Interior Mutability

Personally, I find it a bit difficult to properly explain what mut in patterns does exactly while teaching Rust. The naive "mutable variable" explanation immediately breaks down under numerous exceptions (let y = &mut x;, interior mutability, etc.), so lately I explain it as a "hard lint" which allows creation of mut references from the variable.

It may have been useful to allow us specify additional properties for variable bindings, like "does not/may/does contain interior mutability", "does not/may/does heap allocations", "read-only data", "write-only data", etc. But it quickly becomes a matter of practicality. Would you like to see let mut interior_mutability no_heap x = ...; for every variable? Sure, we may make some of those properties optional (i.e. they would default to "may"), but it would mean creation of different Rust dialects "noisy, but precise" and "imprecise, but lean".

The same applies to references. In some cases it can be useful to guarantee that a reference is "write only" or "does not contain any interior mutability", but every granular property is an additional piece of information which makes code more difficult to read and write. Whether such trade-off is worthwhile depends on the programmer and the area of use.

2 Likes

I mean: for an already initialized object, everything you can achieve by writing x = f(...), you can also achieve by writing g(&mut x, ...). Of course it's observable for the callee which function you have called, but the point is, they can be made equivalent for the caller.

This is not the case in Python.

For example, with lists in Python, these are importantly different operations:

a = a + [5]  # replaces a list with another with 5 appended
a.append(5)   # appends 5 to the same list

But in Rust, these are equivalent operations:

s.push_str("abc");  // one way to append "abc"
s = s + "abc";  // another way to append "abc"
1 Like

Interior mutability is tricky to explain, but I don't see how let y = &mut x; is an exception. The pointer isn't changing, what is behind the pointer can change, hence only one mut out of the two levels of indirection.

Yes, but for some beginners it can be somewhat confusing that we can mutate data using an "immutable" variable.

Some beginners will be confused by most of Rust (or any other language for that matter). I don't think that because something may be confusing to some (new) users should preclude it. I was a beginner with Rust once and the "mut" notion never confused me. I'm sure it didn't confuse a lot of people.

In the above discussions I see a lot about how "mut" doesn't necessarily mean that the underlying value is immutable. So what? It means what it means. I haven't seen one thing in the discussion that shows that it matters for any real reason.

Some vague notion of confusion doesn't warrant changing things (IMHO).

3 Likes

It is an exception in that the &mut T type is "special cased" to not require a let mut declaration to mutably dereference:

let not_mut = &mut 1;

*not_mut = 2;

compiles, but you can't do that with any other pointer type:

let not_mut = Box::new(1);

*not_mut = 2; // Error: cannot assign to `*not_mut`, as `not_mut` is not declared as mutable

Although

let mut x = &mut 1;

repeats mut, it would be more consistent.

1 Like

You can argue that Box is deliberately designed as a container rather than as a pointer, similarly to Vec. After all, dropping the Box also drops the contents, unlike with references. So it makes sense that mutating the contents logically mutates the Box, the contents are treated as part of the Box.

My interpretation is different. It is better without the mut.

It is true that you unfortunately can't replicate that in user-defined "mutable reference" types that behave the same way. That's a shame.

But I claim that the proper solution for consistency would be to enable support for this for user-defined types. Not: add the extra mut in the above example.

The reason user-defined types don't support such dereference is simply that the language is lacking a type it could have for completeness:

  • there is a shared immutable reference type
  • there is an exclusive mutable reference type
  • there isn't an exclusive immutable reference type, even though it would be fine

The last one is what you would optimally want to pass as the self parameter when dereferencing a user-defined mut-reference type. It doesn't need to change, but it needs to be exclusive. But you can't because the language is missing this. So you have to "emulate" it with the second one instead, giving up the immutability of self. This in turn forces users to unnecessarily mark such local variables as mut.

I think the reason it is missing is that it is relatively rare you'd want to use this in practice, but in some cases you would want to use it.

5 Likes

I think your example shows a difference between an implementation that takes advantage of unique ownership and moves, and another one that can't.

The ability to move from a place and forbid inspection of the deinitialized moved-from place is a fundamental difference between Rust and Python, but it doesn't say anything about let, because it's not a property of bindings: the body of Box and struct fields can be moved-from and even reinitialized, and they're not bindings.

Your example happens to show equivalence only because s + "abc" is implemented as s.push("abc"); return s, so the second example boils down to: s.push("abc"); s = s. So it's technically true they're equivalent, but not because let reassignment and mutation via &mut are equivalent, but because it's just an implementation choice of String that makes both lines call push_str() and not change s.

This is what Python can do as well:

def rustlike_add(f, other):
	f.append(other)
	return f

rustlike_add(a, 5)
a = rustlike_add(a, 5) # the same!

The observable difference is not in let itself, but in the ability to use the moved-from source:

let orig = String::new();
let modified = orig + "x";
print(orig); // not allowed
orig = ""
modified = orig + "x"
print(orig)  # allowed

So because the second case in Python is observable, it chose to implement + by copy, as otherwise it would be weird. But that's just an implementation choice. Rust could have chosen that too:

struct List(Vec<i32>);

impl List {
    pub fn append(&mut self, other: &[i32]) {
        self.0.extend_from_slice(other);
    }
}

impl<const N: usize> std::ops::Add<[i32; N]> for List {
    type Output = Self;
    fn add(self, other: [i32; N]) -> Self {
        List([self.0.as_slice(), other.as_slice()].concat())
    }
}

fn main() {
    let mut a = List(vec![1,2,3]);
    a = a + [5];   // replaces a list with another with 5 appended
    a.append(5); // appends 5 to the same list
}

Anyway, assignment and mutation via &mut are not the same. Assignment of an initialized value runs Drop, while &mut doesn't need to.

Because Rust has unique ownership, it's not really possible to create unquestionably apples-to-apples comparison, so this is the next best:

let mut m1 = mutex1.lock();
let mut m2 = mutex2.lock();
m1 = m2; // unlocks the first lock
// m1 has changed - contains the second lock
let mut m1 = mutex1.lock();
let mut m2 = mutex2.lock();
mem::swap(&mut m1, &mut m2); // does not unlock anything
// m1 has changed - contains the second lock

Those are still equivalent. My point wasn't about performance or that the two would generate identical machine code. The two versions have the same semantics. Implementation details differ when you implement them differently, obviously.

There is no logical difference in Rust between "replacing a list with a fresh new list" and "modifying a list". Replacing the whole list is one way to modify it.

I don't think I'm following this. Replacing a list with a new list would allow some other reference to the old list to continue referencing the old list without the new data, while "modifying it" would cause that other reference to see the new data. I get that this isn't an issue in your (very compact and linear) example, and it's not often an issue in Rust (as ownership distinguishes the cases quite well with error messages). But I don't think it is reasonable to conflate the terms.

Show me some example code because I don't know what you're talking about.

Firstly, shared mutability rules forbid either replacing or modifying a Vec (or a List, it doesn't matter) while there is some other reference to it.

Secondly, if you use a pointer to work around that, i.e. have a pointer to the Vec, then if you replace the Vec by assigning another Vec to it (or by using mem::replace), then the pointer will see the replacement.

1 Like

I believe that &mut T being special in this way is strongly connected to it being the only (non-Copy) type that supports [implicit] re-borrowing. (a)

Some notion of exclusive immutable reference also helps against things being special, but IMO the thing that’s special in this regard is more generally the powers of built-in place access, e.g. field access; also built-in dereferencing [for Box for example] and built-in indexing [for arrays & slices for example]). (b)


(a) Implicit re-borrowing is relevant in particular, since one fundamental property of [implicit] re-borrowing is that if you only re-borrow something once, it’s supposed to be equivalent to a move. (Similar to how if you only copy something [implementor of Copy] once, that’s equivalent to a move..)

Thus, it makes sense that

fn f(r: &mut i32) {
    *r += 1;
}

fn main() {
    let mut n = 1;
    let r = &mut n;
    f(r);
}

doesn’t require mut r because the call to f could count as just-a-move…
but then, it’s actually allowed to use-cases where r truly is reborrowed, too – e.g. if you add a second f(r); call in the end.


(b) The special nature of e.g. field access or deref of Box is most notable&known perhaps in their support for borrow splitting, e.g.

fn main() {
    let mut x = Box::new((1, 2));
    let r1: &mut i32 = &mut x.0;
    let r2: &mut i32 = &mut x.1;
    std::mem::swap(r1, r2);
    assert_eq!(*x, (2, 1));
}

but it looks like the same set of built-in place projections also support re-borrowing of their target without mutable access to the container whose element we want to re-borrow.

E.g.

fn main() {
    let mut n = 1;
    let mut m = 2;

    // note: it's NOT `mut x`!
    let x: Box<[(&str, &mut i32)]> = <_>::from_iter([("n", &mut n), ("m", &mut m)]);

    let key = some_key();
    if let Some(ix) = x.iter().position(|&(k, _)| k == key) {
        let r: &mut i32 = x[ix].1;
        *r *= 111;
    }
    println!("{x:?}");
}

(playground)


So it appears to me that user-defined types can support this as soon as

  • user-defined types can opt into implicit re-borrowing, to take the same role as &mut T in this context
  • user-defined types can provide a stronger Deref&DerefMut implementation, to take the role that Box, (A, B and [T] did above. Some way to allow for the same fine-grained borrowing that Box’s Deref or [T]’s Index operator magically offer. Such a mechanism might come out of efforts for a DerefMove-kind of trait (because – I guess – the desirable feature of partially moving out of a DerefMove target might already necessitate sufficient well-behaved-ness that you can then give it the whole support of “what Box can do”)
3 Likes