Mut => Confusion Between Classic Mutability and Interior Mutability

Hello,

I’ve recently encountered a situation in Rust that I find somewhat confusing. Rust generally enforces immutability unless the mut keyword is explicitly used, which I appreciate for the clarity it brings. However, with types that leverage interior mutability (e.g., RefCell, OnceLock, Cell, etc.), it’s possible to modify the internal state of a variable without requiring the variable itself to be declared as mut.

While I understand that this behavior is by design and serves specific use cases, it feels like an exception to Rust's otherwise strict rules about mutability. To me, this introduces an element of inconsistency that can make the code harder to reason about, especially for beginners.

Wouldn't it be more consistent (and less confusing) if Rust required the mut keyword even for variables using interior mutability? What are your thoughts on this design decision?

  • :confused: Now I feel obliged to read the code of every struct to ensure it is truly immutable, because the absence of mut no longer guarantees anything.

Examples

Classic Mutability :

#[derive(Debug)]
struct Model {
      pub x : String,
}


impl Model {
    pub fn edit(&mut self){
        self.x = "Huuu".to_string();
    }
}

fn main() {
    let m = Model { x : "cool".to_string() };
    m.edit();
    println!("{:?}",m);
}

Expected result, (because “mut” must be added to m) :

error[E0596]: cannot borrow `m` as mutable, as it is not declared as mutable
  --> src/main.rs:15:5
   |
15 |     m.edit();
   |     ^ cannot borrow as mutable
   |
help: consider changing this to be mutable
   |
14 |     let mut m = Model { x : "cool".to_string() };
   |         +++

Interior Mutability :

use std::cell::RefCell;

struct Model {
    x: RefCell<String>,
}

impl Model {
    pub fn edit(&self) {
        *self.x.borrow_mut() = "Huuu".to_string();
    }
}

fn main() {
    let m = Model { x: RefCell::new("cool".to_string()) };
    m.edit();
    println!("{:?}", m.x.borrow());
}

Result, (variable modified without being declared "mut") :

"Huuu"

Thank you for your insights!

1 Like

In your example, how is it known that interior mutability is used? Rust deliberately does not inspect the bodies of functions for API purposes.

4 Likes

Then it would just be "classic" mutability again, wouldn't it?

The bigger thing enabled by interior mutability is that it can be shared, especially by otherwise-immutable references. That is, there can be multiple &T referencing the same thing, whereas &mut T means you have exclusive access to the thing. Interior mutability offers a safe way to make modifications even through those shared &T.

5 Likes

I'm not sure if this explanation is correct, but in the case of interior mutability, does the variable m act like a kind of pointer to a memory address, where the compiler doesn't care if the memory at that address is modified, as long as the address pointed to by m remains unchanged?

There is a deeper aspect of this that involves mutation through pointers, when it comes down to the LLVM noalias attribute, which is usually applied to references but not with interior mutability. But no, a RefCell itself is not a pointer at all.

struct Model {
    x: RefCell<String>,
}

impl Model {
    pub fn edit(&self) {
        // contents don't matter
    }
}

Just looking at this -- the &self is an immutable reference (a kind of pointer) to a Model, including its x field. There can be many of these references at the same pointing to the same Model. If we didn't have any interior mutability there, then this &self parameter would get the noalias attribute and then LLVM would optimize that code with the assumption that none of the data can change. But since there's a RefCell, it has to be more conservative and assume that it could change on the fly.

At the programmer level, this is more of a question of API design. If it's going to be surprising to your user that edit(&self) can change the model, then don't do that! Make it edit(&mut self) instead.

But you could imagine instead that the model also had some field that you do want to update even through shared a "read-only" method like fn len(&self) -> usize -- perhaps a cached computation or some logging metrics, for example. That could be a more reasonable place for interior mutability.

2 Likes

I take it you mean mut bindings like let mut. In which case, this is only sort-of true even ignoring shared (interior) mutability. You can mutate through a let non_mut_binding = &mut foo, you can move non-mut bound variables to mut bound variables, and you can end up calling the non-trivial destructor of non-mut bound variables too (Drop::drop takes &mut self).

Some people feel very strongly about the importance of mut or non-mut bindings, but I consider them to basically be a hard-required lint. You can't rely on it without examining the rest of the code (even ignoring shared mutability), and mut bindings doesn't show up in rustdoc output, because ownership and the ability to move is a stronger capability than a single mut binding.

(The lint boils down to: you can't overwrite or take a &mut _ to a non-mut bound variable. The rest is fair game.)

Unfortunately, most learning material -- including The Book -- presents a "mutable or immutable" perspective. But "exclusive or shared" is much closer to the reality when it comes to Rust. For example, you've discovered that &_ references are not immutable (but they are shared). Similarly, &mut _ references demand exclusivity (even if you don't mutate through them). Here's an article on the topic. The article also links to this pre-1.0 post by Niko, which has one of the best summations of this topic IMO:

it’s become clear to me over time that the problems with data races and memory safety arise when you have both aliasing and mutability. The functional approach to solving this problem is to remove mutability. Rust’s approach would be to remove aliasing. This gives us a story to tell and helps to set us apart.

I.e. it's never really been about immutability.[1]

That's impossible without some global, deep effect system. You can be a structure that just holds an integer, but that integer could represent a pointer or a file handle, etc, that can effectively demonstrate shared mutability.

Moreover, it's probably not something you actually want for multiple reasons. Shared mutability lies behind every synchronization primitive, and (though outside the language) behind many OS primitives as well. You're making use of it with every println!, Rc, Mutex, File, atomic, etc.

As a more concrete example, I had a conversation with someone who was fine with the shared ownership of Rc<T> on some level since it only yields &T and not &mut T. But how is the reference counting implemented? It's implemented with shared mutability of the counter. They were of the opinion that the shared mutability of the counter should somehow be magically permitted because they only really "cared" about the T. But there's nothing at the language level to make the shared mutability of the counter special. They had some human-reasoning based desire for immutability that didn't translate to the technical reality.

I do feel there are solid improvements to be made for newcomers by changing learning material such as The Book to present the shared-vs-exclusive perspective and to stop presenting a misleading view of immutable-vs-mutable.

Indeed, if you wanted to be sure there was no shared mutability anywhere, you would have to know not only the fields, but every implementation, arguably including foreign implementations, as one can always stash some global token based on a type somewhere and create some sort of impure behavior off of that.[2]

That being said, it's reasonable to say things like "&str is not just a shared reference, but a truly immutable reference, because str contains no shared mutability".

However, in a generic setting, there is no way to know if the generic has shared mutability.[3] And this is fine. Your generic consumers want to be able to utilize shared mutability. They don't want to see some error about using Arc<str> instead of &str, say. Or some type of their own that features shared mutability more directly.

One has to undergo an annoying mental shift and relearning phase if they were taught the immutable-vs-mutable perspective. Certainly I did. But at least personally, things were much improved after I made the shift, and realized that shared mutability was not the devil, and was present in wide swaths of the standard library and ecosystem.


  1. I do wonder if we would have at least gotten a more accurate spelling than &mut if that pitch hadn't bundled in the dismissal of mut bindings, but alas. ↩︎

  2. Silly example: open a temp file based on the Debug representation of a struct and use file operations to store state. ↩︎

  3. You may here about the Freeze trait. Don't be misled; Freeze represents a shallow property. A Box<Cell<T>> is Freeze. A file handle is ultimately an integer that is also Freeze. ↩︎

8 Likes

Rust has been trying to switch away from using the terms "mutable" and "immutable" (but syntax can not change easily and most beginner resources still use "mutable"/"immutable"). Instead, the mental model should be "exclusive" and "shared".

Exclusive (&mut) means that mutation can occur soundly without limitations.

Shared (&) does not necessarily mean immutable though: it just means that mutation needs to be "synchronised" in some way to prevent data races.


This is further muddied by the (already inconsistent[1]) let [mut], which is not necessary for soundness reasons (ownership already guarantees exclusivity), and is often described as "just a lint". I think it makes sense to treat it as such (it is mostly useful, but it can "be wrong"). The unfortunate part about it imo is that it uses the same syntax (mut) as the actually important exclusive references.


  1. let a: &mut T can be used for mutation, despite not marked mut ↩︎

4 Likes

Think of "mut" as "mutually exclusive" instead of "mutable" and otherwise as "shared" and things make more sense.

9 Likes

I don’t believe that a switch away from “mutable” and “immutable” in Rust is desirable.

Rust’s notion of immutability isn’t any worse than that of many many other programming languages which don’t have a strictly enforced notion of pureness.

This includes even many functional programming languages which praise themselves with good conventions around “immutability”.

The only thing that Rust does different than some of these languages is that (A:) Rust does not tightly couple interior mutability with (managed) heap allocations and shared state and “objects”; and (B:) that many of Rusts “immutable” data structures do allow efficient in-place operations anyways, in case you have unique ownership or exclusive/unaliased explicitly-“mutable” access to it.

More on (A:) – Realistically, I don’t see why this decoupling should make the notion of mutability less valuable though. What other programming language doesn’t allow some form of mutation of record fields / objects, or at least offer some “reference” or “cell”-style interface for shared mutable values? And does any of those languages require you to mark the struct/object/whatever that contains this mutable field/ref/cell to be marked as mutable? As far as I’m aware, a very common property of mutability markers on local variables – across many programming languages – is that they’re inherently “shallow”.[1] In practice, I believe that variable mutability/const-ness of Rust is generally a lot more meaningful than the analogous notion in many other programming languages.[2]

More on (B:) – The explicitly exclusive/unaliased mutability of Rust arguably is an additional feature of the language which many other programming languages don’t have at all. Usually it’s framed as a limitation, but arguably, Rust doesn’t really limit “classical” mutability (in the sense of how many other programming language interpret it) it just calls it “interior mutability” instead. The additional Rust-style “mutability” alone however is so useful and powerful that it’s widely used and managed to become the “normal” notion of mutability in Rust.

It might seem weird to think of all basic data structures, e.g. Vec or HashMap etc… in Rust as immutable data types but arguably that’s exactly what they are.

An immutable data type is one where if you have it in an immutable variable let x = …value…; then this value can never change.[2:1] Here, Vec<i32> or Vec<HashMap<String, HashSet<(i32, f64)>>> is no more mutable than bool. More precisely, Vec or HashMap aren’t always “immutable data types” in this sense (they’re generic after all), but more concretely just Vec<T> or HashMap<K, V> are immutable data types if T or K and V are immutable data types, respectively.

Any variable of any type can be marked “mutable” in Rust, which is a property of the variable, and you can always mutate such a variable by replacing its entire contents with a new value. Rust just makes replacing one Vec with another one really really efficient for the cases where you own the value.

Besides the efficiency, and mental modal, this feature-set isn’t all too different from pure functional programming though. A re-assignable local variable isn’t too different from introduction of a new variable of the same name even in Rust, in many use cases; one case where the difference does matter is in loops, but those aren’t really fundamentally any more expressive than recursion; and when you work with &mut T references in more complex ways… those – Rust’s “mutable references” – are IMO in many ways morally equivalent to “functional references” semantically.

They are still a bit different from those… a &mut T reference cannot contain any custom abstractions that pre-processes or post-process your reads or writes of T through the referencethe reference. On the other hand, we also gain additional capabilities through this limitation; I don’t believe ordinary “functional references (aka lenses)” could reasonably support all the patterns of re-borrowing and borrow splitting[3] that Rust’s mutable references can; and if nothing else, their true nature that “it is just a pointer” means peak efficiency; in this sense, they’re a zero-cost abstraction.[4]


  1. In Rust, mutability of a variable is just a little bit “more shallow” in that you don’t require any heap/indirection for interior mutability, just UnsafeCell (or a safe wrapper of it); on the other hand, the common practice of avoiding interior mutability in basic data structures means that mutability of a variable can be a lot “less shallow” as it does apply [at least in an API-safety kind of way] to the mutability of data behind heap indirection. Because it’s only UnsafeCell (or a safe wrapper of it) that limit the “reach” of variable mutability markers. ↩︎

  2. Arguably, Rust makes one exception here. &mut T itself is very first-class, compared e.g. to “inout”-param features of other languages, or “functional references” (see more below), but they do have this quirk that they’re the only type that does allow mutable access to a pointee without being marked mutable themselves. Thus if you have let x = &mut …some place else…; then x itself can “change” observably – in that at least the value it points to can change – without the variable needing to be marked mut, nor interior mutability be used.

    I can’t say I love this detail; but I can also see how let mut x = &mut y; would feel repetetive with the double mut. ↩︎ ↩︎

  3. which can get very complicated – e.g. you can re-borrow (and/or borrow-split) things in a loop ↩︎

  4. You can’t capture the features that arbitrary “functional references” can do, with custom getter and setter logic, which would allow custom transformations of values that are read or written, and allow “simulated/virtual” fields, a “reference” to – but this means you don’t pay for what you don’t use, a generalized type that would allow this would need to include overhead to allow for this possibility. ↩︎

5 Likes

I'm one of these people that feel strongly about immutable bindings, so let me argue against this point. I disagree about "you can't rely on it". You can rely on an immutable variable not changing (except via interior mutability).

The only way you can kind of "change" it is via shadowing by a variable with an identical name. But if you believe this makes immutability unreliable, then the whole type system is unreliable the same way:

    let x = String::from("abc");
    let x = 17;
    // wait a second... I thought `x` was guaranteed to be a `String`

@cuviper

Does LLVM really need us to help with "mut" to determine if a variable will be modified somewhere or not? I believe that with the execution tree it generates, it can detect this behavior on its own.

@quinedot

I really like the way you represent the notion of "mut" as either exclusive or shared. Thank you for your explanations!

@steffahn

In C++, everything is mutable unless explicitly declared as "const" which simplifies things a bit. In Rust, it's quite the opposite: everything is immutable unless declared as "mut."


The question that arises is: is the concept of "mut" really necessary, since it's not always guaranteed? Hasn't it just added complexity (perhaps unnecessarily) to the language, not to mention the need to duplicate methods in two versions, such as xxx() and xxx_mut() (e.g., iter() / iter_mut(); borrow() / borrow_mut(); etc.)?

In order to understand the point of the code example of

pub struct Model {
    x: RefCell<String>,
}

impl Model {
    pub fn edit(&self) {
        // contents don't matter
    }
}

note that the right context to interpret this is that Model and Model::edit are public API, and the compiler would want to compile this function / public API without knowing all the callers in advance. Or conversely, compiler a caller of this function – or perhaps even a function pointer of the same signature – without knowing the implementation.

For this kind of setting it’s important that all information from the type signature alone – and it’s meaning in Rust’s memory model that might be useful for better optimization of the code (on either side, caller or callee) is included; which is why information about mutability and/or aliasing will generally be useful.

While some people debate the usefulness of let vs let mut, the difference between &T and &mut T is much more significant, and the duplication of many methods that comes with it is arguably worth it: those methods iter vs iter_mut; borrow vs borrow_mut are about &T vs. &mut T, not about mutability of local variables. &T shared “immutable” references in Rust; and &mut T, exclusive “mutable” references, have quite a lot of different properties.

8 Likes

@steffahn

thank you very much for all these explanations, I hope this post will help a lot of people who have the same questions.


Once again, thank you all for taking your precious time to respond. :pray:

Local let mut doesn't show up in LLVM IR at all -- at others have said, that's really more of a lint for compiler intent. LLVM only sees attributes on pointers in function arguments, where Rust adds some combination of noalias and readonly depending on the type of pointer and interior mutability.

We've discussed this a million times, but I'll reiterate that this depends whether you think of x being the binding itself, or x being a symbolic representation of the value it holds.

And which perspective is right is also a bit philosophical – you can say it's only about the binding itself, and moves and shadowing don't count. This interpretation is tautologically valid, because that's what Rust defines let to be.

Or one can critique let as a tool, measuring it not by its own definition, but by its ability to meet users' needs, such as knowing whether the data behind x changes or not, including through moves to another binding.

let x = Detonator::new(); // is this immutable?

{x}.mut_explode(); // I can call &mut self method 

so while the binding is "immutable", the ease of moving to another binding makes this not reliable to me. I don't mean it as a bug in the language, but in the sense that I can't rely on the value behind the binding not mutating just because I don't see mut where it's bound to a binding. The fate of the original binding is uninteresting to me, and doesn't give me the information about mutability that I want.

OTOH I would consider this reliable:

let x = &Detonator::new()

Because I can at least know that &mut methods can't be called on x or anything that it's copied or moved to, and I don't need to review subsequent code to prove that mut_explode isn't called on this instance.

4 Likes

My view is that Rust doesn't really have immutable objects or immutable data. It has pragmatic protection against uncontrolled shared mutability, but that's not the same as immutability.

Rust only has different modes of accessing objects.

&str is immutable for as long as it can be accessed through this reference, but the string data behind it could have been mutated before and after. &'static str of string literals can use truly immutable memory, but it gets away with this because you're never given &mut str access. &'static str from a leaked Box will point to regular heap memory.

When you have exclusive ownership or exclusive access, you can mutate*. let mut x = x is legal.

When you have shared access, there are restrictions on what you can do, but immutability is only one of the options, and it can also be single-threaded shared mutability, multi-threaded atomic or mutex.

I don't quite like Rust's terminology for "interior mutability", because when used with immutable vs mutable terminology it sounds paradoxical like saying red color has interior blue color. Shared vs exclusive makes more sense, because then these types let you change shared+immutable into shared+mutable access, and there's no contradiction about this being shared.

* field privacy may stop you from changing data in a struct directly, but that's also due to your access, not some inherent immutability of the object. mem::swap lets you replace data in a place, even private fields, which is very powerful and the reason why Pin+!Unpin had to be created and pin projection needs to be controlled.

6 Likes

Separating the variable from the value it holds only makes sense to me in a language where variables point to values "elsewhere" (on the heap), such as Python or ML. Whereas in Rust variables directly hold values. It's also why I find the "binding" terminology confusing and really inapplicable to Rust and prefer a simple "variable".

To me the essential value of immutable variables can already be demonstrated with Copy types:

let x = 0xdeadbeef; // I'm safe asleep knowing this will always be 0xdeadbeef
let y = x + 6; // This "copy" is different but it's irrelevant.
let mut z = x; // This copy can also be different later but that's irrelevant too.
let x = x + 17; // That's different but we all know it's just a "trick" of using the same name for a different thing and the original unchanged x is still there.

The only difference for non-Copy variables is that I can no longer use the original after I do one of the above, but that's a separate concern.

If you try to ascribe a different philosophy to values then sure, the mutability will become "unreliable", but only because that's not Rust's philosophy of how values work, it's more like Python's. The Rust interpretation is reliable.

I don't see the storage location or levels of indirection as an important distinction. Copy/!Copy/Drop changes the behavior of bindings/variables, not where the values are. Types like Rc blur the line between what's directly owned and what's a Python-like "owned elsewhere" object.

Zero-sized !Copy objects aren't even meaningfully stored anywhere, but still can have &mut self methods and interact with let/mut.

Even for a zero-sized !Copy object I may want to get a quick answer to "Can any code in this scope call any &mut method on it?". I wish Rust had a feature for this, but unfortunately it doesn't, and I'm disappointed that let is not the right tool to answer such question.

Yes, that's exactly what I've meant. Rust has a philosophy about what bindings are and when they need mut added, and that is solidly implemented by its own definition, but it's just not a useful definition for reasoning about more broadly interpreted mutability of objects, in a Python-like sense.

The big difference is that in Python (or Javascript, etc), assigning a new value to a variable and mutating a value are semantically different operations. For example in Python you need a global statement if you're going to assign a new value to a global variable but not if you're just mutating it. In Javascript, const allows mutating but not assignment.

But in Rust there is no difference by design. Mutating a variable is equivalent to assigning a new value to the variable. So it makes no sense to try to make a distinction between whether a different object has been moved in, or whether it's the same object that has been mutated. This is why Rust provides no such functionality, it would break the equivalence.

1 Like

global is only due to scope ambiguity, since there is no mandatory variable declaration.

But in Rust there is no difference by design. Mutating a variable is equivalent to assigning a new value to the variable.

I don't know what you mean by that — do you assume a specific definition of "mutating" that applies to the language construct of bindings, and is not in any way related to run-time behavior and using &mut?

Do we still philosophically disagree whether x in let x is a label for itself, or a means for accessing the value it owns? Do you mean that mutating x is a logical programming construct, rather than use of &mut that writes some bytes to memory?

By my definition, mutation is more closely related to writing bytes to RAM or changing program state*. From that perspective there's an obvious difference between a binding reassignment and mutation, at very least Drop makes it observable.

*) actually precisely defining mutation is tricky. I don't mean that bytes in RAM changing exactly equals mutation. For example, a move that doesn't create or destroy any instances wouldn't count as mutation to me, even though it might memcpy. Optimized-out use of &mut would still be a mutation to me, because I care about the language-level semantics of being able to call &mut methods. Lots of edge cases, but I just want to draw a general distinction between symbolic manipulation and run-time changes.