D22 · Mutable vs Immutable
By the end of this lesson you will be able to look at any Dart value and say where it lives, who else can reach it and whether it can change. You will know exactly what final, const, List.unmodifiable, UnmodifiableListView and a defensive copy do to memory, and you will be able to write an immutable class, copy a nested structure correctly and avoid the classic bugs (shared inner lists, lost map keys, leaked internals).
1. Variables, objects and references
Some words, defined once:
- Stack: the small, fast area where each running function keeps its local variables (the sticky notes).
- Heap: the big shared storage room where objects such as lists, maps and strings live.
- Reference: an arrow from a variable (or from a field of another object) to an object on the heap.
- Mutable: the object can be changed after it was created (the house can be repainted). Immutable: it never changes; every "change" gives you a different, new object.
- Aliasing: two or more references to the same object. It is harmless for immutable objects and is the source of most bugs for mutable ones.
Watch aliasing happen. A list is mutable; the line var b = a; copies the arrow only.
Now the same idea with a number. An int, double and bool are immutable values: nothing in Dart can edit the number 5 in place. y++ means y = y + 1: compute a new number, store it in the box named y.
A String is a heap object, but it is immutable too. Look at what += really does.
| Mutable (can change in place) | Immutable (every change makes a new object) |
|---|---|
List, Map, Set (plain, growable or fixed-length), Queue, StringBuffer, Random, any class with a non-final field |
int, double, num, bool, BigInt, String, null, DateTime, Duration, Uri, enum values, const collections, List.unmodifiable results, records (shallowly), classes with only final fields |
final is not the same as immutable. final list = [1, 2]; only forbids list = somethingElse;. list.add(3) is still allowed. The next section makes the three ideas separate.int is 64-bit on native platforms and a JavaScript number on the web; immutability is the same on both.2. final vs const vs an immutable object
final is a name tag glued to a box: you can never move the tag to another box, but you can still rearrange what is inside the box. const is a sealed, shrink-wrapped box made in the factory (compile time): the tag cannot move AND the contents cannot change. An "immutable object" is any box whose contents cannot be rearranged, however you got it.| What it fixes | When the value is known | Object content mutable? | |
|---|---|---|---|
var x = ... | nothing | run time | depends on the object |
final x = ... | the variable (assign once) | run time (e.g. DateTime.now()) | depends on the object: a final list can still grow |
const x = ... | the variable and the object, deeply | compile time only | no: const collections throw UnsupportedError |
| immutable class | every field is final (and holds an immutable value) | run time (or compile time with a const constructor) | no |
List, so the compiler lets add through and it fails at run time with UnsupportedError. The same is true for lists made by List.unmodifiable.final/const) and "can this object change?" (is it a mutable type, and is it const/unmodifiable?).3. const canonicalisation and identical()
const expression, Dart does not make a new copy of the document: it hands you the one original that was stamped when the program was compiled. Many people hold the same document.Canonicalisation means: all const objects with the same type and the same contents are ONE object. identical(a, b) asks "are these the very same object?" (it compares arrows, nothing else). == may be customised by a class; for List/Map/Set it is just identity.
Edge cases, all checked by running real Dart:
const <int>[1] and const <num>[1] are different objects. A const object can only contain other const things (numbers, strings, other const objects), which is why const collections are deeply immutable: there is nothing mutable inside to reach.4. Unmodifiable views vs unmodifiable copies
| Made by | Copies elements? | Sees later changes of the source? | Cost to create | |
|---|---|---|---|---|
| copy | List.unmodifiable(x), Set.unmodifiable(x), Map.unmodifiable(x) | yes (new frozen object) | no | O(n) |
| view | UnmodifiableListView(x), UnmodifiableSetView(x), UnmodifiableMapView(x) (import 'dart:collection';) | no (wraps an arrow) | yes | O(1) |
Type your own list and the value to append, then step through. Both the copy and the view are built from the same source list.
Writing through either one fails. Here is the complete list: every mutating method of a list, a map and a set on an unmodifiable object throws UnsupportedError. Reading, searching, toList() and spread still work and give you a normal mutable result.
List.unmodifiable([inner])[0].add(2) still changes inner. For a whole nested structure see question q25 (deepFreeze)._source); view[i] asks the source for element i, so it costs the same as reading the source (O(1) for a List). A view protects only the reader: whoever still holds the original can mutate it.5. Immutable classes, copyWith and records
copyWith is the print-a-new-one button: it copies every field, changes the ones you name and gives you a new object.Recipe for an immutable class: (1) every field is final (and holds an immutable value), (2) a const constructor when possible, (3) override == and hashCode from the same fields, (4) copyWith for "changes". The package:meta annotation @immutable asks the analyzer to warn if a field is not final; it is only a lint, the language itself enforces nothing beyond final.
A record such as (1, 'a') is also immutable: you cannot assign to r.$1. Records have value equality (== compares the fields), but they are immutable only shallowly:
List field is not really immutable: final List<int> items; forbids re-pointing items, not items.add(1). Copy and freeze the list in the constructor (section 7).6. Shallow vs deep copy
All of these are shallow: List.of(a), List.from(a), a.toList(), [...a], a.sublist(0), Map.of, Set.of. They copy the outer container (O(n)); inner objects are shared. For a deep copy, copy every level yourself. Type nested rows and watch the arrows (1-3 rows, 1-3 numbers each).
The same sharing is behind a very common bug: List.filled puts one fill object in every slot.
deepCopy); it costs O(N) for N nodes and cannot handle cycles.7. Defensive copies
When a class stores a list that came from outside, or returns its internal list, the outside code keeps a rope to the class's private state. Fix it at the two doors: copy on the way in (List.of(items) in the constructor) and hand out a read-only view on the way out (UnmodifiableListView(_items), O(1)).
List.of(_items) from the getter would cost O(n) every call and the caller could not see later changes.8. == / hashCode and mutated keys
A Set or Map uses an element's hashCode to choose a bucket (a numbered compartment); == then picks the right element inside it. The contract: equal objects must have equal hash codes, and the hash code of an object stored in a hash collection must never change. The easy way to guarantee it: build ==/hashCode only from final fields (Object.hash(a, b) combines several). A List as a key is safe in a different way: it uses identity, so it ignores contents.
The picture below is a simplified model (a table of 4 buckets, bucket = hashCode mod 4); real Dart tables are bigger and resize, but the failure is the same.
Set.of(s) or s.toSet() may clone the old table and keep the stale bucket. Rebuild it through a list instead (Set.of(s.toList())), or better, never mutate keys.9. Strings are immutable: StringBuffer
String is a sentence engraved on a stone tablet. To "add a word" you must engrave a whole new tablet (copying the old words). A StringBuffer is a notepad: you keep writing, and only at the end do you engrave ONE tablet with toString().Because strings are immutable, every s += w in a loop copies everything built so far: for n pieces that is about 1 + 2 + ... + n pieces of copying, so Θ(n²). A StringBuffer collects the pieces and builds one final string. (This describes the Dart VM; on the web, JavaScript engines may optimise += internally, but writing to a buffer is the habit that is fast everywhere.) Type your own words (1-5 words, 1-6 letters or digits each, separated by commas).
All other String methods (toUpperCase, replaceAll, trim, substring, +, *) return a new string and never modify the receiver; see the member cards below.
10. Mutability and isolates
Each isolate has its own heap. A message you send, and any variable a closure passed to Isolate.run captures, is copied into the receiver's heap, so mutable objects are never shared between isolates. Static and top-level variables are also separate per isolate. (Isolate.exit and TransferableTypedData can hand data over without a full copy, and deeply immutable data may be shared by the VM invisibly.)
11. Changing a collection while looping
An iterator remembers how many times the collection was modified when the loop started. If the collection changes structurally (length, keys) during the loop, the next step throws ConcurrentModificationError. The safe fix is to loop over a copy, or to collect changes and apply them after.
Fixed-length lists sit between mutable and immutable: the slots are fixed (no add/remove/length change) but the content is still writable.
12. Every member: does it mutate the receiver or return something new?
Every member this lesson relies on, grouped by purpose. The badge says what happens to the receiver: mutates changes the object you called it on, new copy/new returns a different object, view returns a live window onto the same data, read-only changes nothing. The examples were run with Dart 3.11 and their output is exactly what is shown. (The complete List/Set/Map member lists are the subject of D26 and D27.)
13. Choosing the right tool
| I need ... | Use | Sees later changes of the source? | Cost | Can the receiver mutate? |
|---|---|---|---|---|
| a normal working list | [...] / List.of | n/a (own object) | O(n) to copy | yes |
| a private snapshot nobody can edit | List.unmodifiable(x) | no | O(n) | no (UnsupportedError) |
| to expose an internal list read-only | UnmodifiableListView(x) | yes | O(1) | no, but the owner can |
| a constant table known at compile time | const [...] | never changes | 0 at run time (shared) | no |
| a fixed number of slots | List.filled(n, v) / growable: false | n/a | O(n) | elements yes, length no |
| to build a long string | StringBuffer | n/a | amortised O(1) per write | buffer yes, final string no |
| a value type used as a Map key | immutable class with final fields, ==, hashCode | n/a | O(fields) per compare/hash | no |
| to give an isolate data | send it (copied) | no, separate heaps | O(size) | only its own copy |
Quiz
Interview questions
Cheat sheet
Rules of thumb: assignment copies the arrow; final fixes the variable; const fixes the object; a copy constructor is shallow; make fields final and copy at the doors; never mutate what feeds hashCode; each isolate gets copies.
| Mutator on a plain list | What an unmodifiable list does |
|---|---|
add addAll insert insertAll remove removeAt removeLast removeWhere retainWhere removeRange clear setAll setRange fillRange replaceRange sort shuffle []= length= first= last= (21) | all throw UnsupportedError |
Map: []= addAll addEntries clear remove removeWhere putIfAbsent update updateAll (9) | all throw UnsupportedError |
Set: add addAll remove removeAll retainAll removeWhere retainWhere clear (8) | all throw UnsupportedError |
| Member | Receiver | Time (why) | Can throw |
|---|