Sorry for the long post, but this gets into the “why.” Despite the name, mutability is not just about change, it’s about variables sharing an instance’s data.
julia> x = y = [1]; # 2 variables to same instance
julia> x[1] = 2; (x, y)
([2], [2])
As you can see, the 2 variables share the same instance, including its mutations. There is a way to check for this identity:
julia> x === y # checks if 2 inputs share 1 instance
true
If we instantiate a mutable type twice, you get 2 instances, even if starting at equal value:
julia> x = [1]; y = [1]; x === y
false
julia> x == y # implemented to check for equal value
true
You can think about it as two independent memory addresses (it’s how it’s implemented). They can mutate separately, and if the segments happen to mutate to the same value again, they won’t merge into one. An instance’s address is fixed so that many variables can have their own copies of the address to find the instance; if it could change, you would need to track and change multiple copies, which isn’t feasible. So, the unique address ironically is not stored at a unique address.
Immutable values have a different kind of identity. Equality (implemented as comparing bits content) means the same instance, even if they were instantiated separately:
julia> x = 1; y = 1; x === y
true
Immutable values can still be involved in change, but only by reassigning a different instance to a variable, one at a time. You can’t change the instance itself in any way, you can’t reassign any field or element of it.
julia> x = y = 1;
julia> x = 2; (x, y)
(2, 1)
At this point, you might be thinking that mutables are more flexible and more straightforwardly have each instantiation make a new instance with its own address, so why do immutables exist? We have to talk briefly about stack memory and heap memory. It’s a fairly complicated subject, but you don’t need to know much here.
A function call puts a stack frame holding data onto the stack; when the call is done, the frame vanishes. If a mutable instance is stored in the stack frame, the instance vanishes when the call is over; you’ll never be able to return it. So, mutables are usually stored in heap memory. Unfortunately, allocating data on the heap is slower, and the garbage collector has to stop the program to figure out if a mutable instance is no longer needed and delete it. It’s common practice to reduce heap allocations as much as possible in performance-critical code, and this often means reducing instantiation of mutable types. A compiler optimization is proving a mutable instance does not escape the function, so it can be allocated on and freed by the stack instead.
An immutable instance cannot mutate and is not identified by address, so it can be copied around in memory, just like the address of a mutable instance. If an immutable instance is stored in the stack frame, the value can just be copied outside the stack frame to return before the stack frame vanishes. If a variable is reassigned a different immutable instance with a fixed size, you don’t need a different address, you can just overwrite the same segment of stack memory. Another way that not needing a separate address helps is inlining immutable elements into an array or struct so the garbage collector only needs to track that 1 array or struct, not all the different elements and fields.
In the same vein, a mutable’s fixed address gets to be on the stack or inlined. There are data structures with memory of unfixed size that move around on the heap for more room, but the primary address will remain the same, it’ll just hold another varying address. As you’ve noticed, an immutable struct stores fixed addresses of the contained mutable instances, so you can’t reassign different instances to the fields. However, you can use the fields to access and mutate the mutable instances.