It's true, most learning material (including the Book) start with a "mutable versus immutable" presentation of the language, and then later say "oh except this Moon sized hole called interior mutability!". (Then, if you learn like I do, you have to go re-learn 2/3rds of the material while keeping in mind this newly revealed truth.)
But the reality of the language is much closer to "exclusive versus shared". For example, &mut _
are exclusive references, and it's UB to alias an active &mut
, even if no mutation occurs. Think of it as "mutually exclusive". &_
are shared references. If there's an UnsafeCell
behind it, mutation may still be possible. This is documented as "interior mutability", but you can also consider it to be "shared mutability". And as covered in the Brubeck article, shared mutability also enables the implementation of shared ownership types, like Arc<_>
and Rc<_>
.
It wouldn't surprise me if it is true that newcomers overuse interior mutability before learning more Rustic design patterns. However, I think it gets a bad rap overall.
There are even official docs that poo-poo the use of interior mutability generally, but the reality is that it is all over the place (though often wrapped up in such a way you may not notice). That is, even if you're not using it "directly" in your own types, there's a good chance you'll still end up using it via:
- shared ownership types (
Arc<_>
, Rc<_>
)
- thread handles
OnceLock<_>
and friends
stdin
and stdout
accessors
- notionally, many other OS primitives
And it can even be useful in ad-hoc situations.
These are reasons why, while I'm on board with encouraging designs that avoid unnecessary shared mutability, I find calling it a "last resort" to be hyperbolic.
Shared mutation primitives provide a safe abstraction for what would otherwise be a lot of unsafe
and raw pointers, most likely. It also maps well to how many system interactions (such as file handles) work.
In my experience, it's also not uncommon for people with an initial reaction against "hidden" shared mutability in Rust -- like in a generic -- to really be okay with some types of shared mutability, but not others, when pressed. Like Rc<_>
without RefCell<_>
is fine for some reason. But there's no formal difference between the "okay" types and the "bad" types. Rc<_>
needs shared mutability for its reference counting to work.
First some words on what the mut
declaration is and isn't about. Note that while &_
and &mut _
are two different type with important differences, let x: T
and let mut y: T
both declare variables of the same type T
. In this case, the mut
or lack of mut
is a property of the binding -- the variable -- and not a type-level difference. Without a mut
binding, you cannot
- overwrite the variable once initialized
- take a
&mut
to the variable
But those are about the only limitations. For example, you can move the variable to a new binding -- including a new mut
binding. So if you have a let s = "hello".to_owned()
, for example, the data owned by the s: String
is not intrinsically immutable, even though there was no let mut
and no shared mutability.
It's just that binding which has the restrictions. Which is still useful, but perhaps not as powerful as some newcomers believe.
If you have a compiling program, you can change every non-mut
binding to a mut
binding, and it won't change the semantics of the program (but will generate many warnings).
Next note also that the mut
binding requirement is also waived for &mut _
in a sense. You can have an let x: &mut T = ...
and mutate through the x
. You don't need let mut x: &mut T = ..
. You just can't mutate (or overwrite) the x
itself.
Having pointed that out, let's try a warm up question. Should mut
be required in the arguments here?
fn example(rc: &Rc<str>) {
let _my_rc = rc.clone();
}
And if so... how would that be annotated? The only binding is rc
, which is a shared reference that does not need to be mut
. *rc
is what contains the shared mutability... in some private field we can't name. rc: &mut Rc<str>
changes the type of the argument and which programs can compile.
Warm up question 2: should this required mut t
?
fn example<T: Clone>(t: T) {
let _ = t.clone();
}
There might be shared mutability involved.
And another warm-up question: Should it be required here?
pub struct OffAndOn(pub bool);
impl fmt::Debug for OffAndOn { ... }
fn example(oao: OffAndOn) {
println!("{oao:?}");
println!("{oao:?}");
println!("{oao:?}");
}
This may also disguise a form of interior mutability, in a sense.
So: why isn't let mut
required when there's shared mutability "present"?
First we'd have to nail down what being "present" means. If we want to include things like managing state via globals, leaked pointers, transmuted types, system resources like file handles, or foreign data over FFI... there's no way to track shared mutability being present without a full blown effect system that conservatively considers all of those things to be a form of shared mutability. In order to be accurately require mut
, that effect system would have to permeate everything. Otherwise it wouldn't be able to apply in the face of things like generics. It would require some global analysis, and just isn't practical.
So that's one reason.
Let's scale back our ambitions. How about, we only track whether or not there is an UnsafeCell<_>
in some field. As it turns out, there's a trait for that. Hmm, it has this note though...
whether a type contains any UnsafeCell
internally, but not through an indirection
Through an indirection, what's that mean? It means that, for example, Box<Cell<u32>>
is Freeze
. That doesn't seem to really be what we want...
fn main() {
// This one requires `mut`...
let mut a: Cell<u32> = Cell::new(0);
// ...but this one doesn't?
let b: Box<Cell<u32>> = Box::new(Cell::new(0));
a.set(1);
b.set(1);
}
Freeze
's job is not to forbid reachable shared mutability, its job is to check if types can be put in read-only memory -- to check if they have immediate shared mutability.
If you wanted to tackle reachable shared mutability, you're heading back onto the messy situation discussed above. It's possible to store a *const Cell<_>
as a *const ()
for example. If you had a custom Box<_>
-like type, the compiler would have to again do some sort of deep analysis of all related code to (try to) detect shared mutability. It can't just check the field types.
...and even if there was a trait for reachable shared mutability, it still wouldn't really be what you want in the generic use case; if you didn't have a DeepFreeze
bound, the generic could still contain or not contain shared mutability.
Ultimately I feel the reason that there's no simple way to detect (or forbid) shared mutability is that forbidding mutation isn't the goal. Quoting Niko,
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.