Lesson 13 / 14
Interior Mutability
How the same 'one writer or many readers' rule is tested with a counter at runtime instead of compile time is measured; that a binding itself can stay immutable while the value inside it becomes modifiable through RefCell, and that a failed borrow attempt can be taken as a bool result instead of a panic, are shown. The rule does not disappear, it changes location.
Published Updated
In this lesson
- Runs code showing that the same "one writer or many readers" rule is tested at runtime instead of at compile time
- Proves with an example that the value inside a binding can be modified through RefCell while the binding itself stays immutable
- Shows that a failed borrow attempt can be taken as a bool result instead of a panic
Contents
Across the previous three lessons a shared value could always be read; it could never be
modified in Rc or Arc, while in Box modification was already possible because there
was a single owner (if the binding took mut). The Variables and Immutability lesson had
also left a question open: it established that a binding not taking mut guarantees no
change is allowed through that binding’s own name, but the question of whether the same
data could be shared through another binding or reference, and if shared, whether
immutability would still hold from there, was left to this course. This lesson answers
exactly that question: is there a way to modify a value both while ownership is shared and
while the binding looks immutable? What this course measures stays the same throughout —
where a value’s life ends — not how a running unit stops, the way the Go curriculum’s
Concurrency course measures.
The answer is yes, but this change has a cost; this lesson measures that cost, and as the topic’s fourth lesson, brings together the topic’s four measurements so far.
The Atomic Reference Counting lesson left this need explicit: a line trying to write to a
field through an Arc did not compile, because it had been shown that knowing how many
copies can access a shared value at once is not enough to modify that value safely. The
same limit held for Rc — in Box modification was already possible because there was a
single owner (if the binding took mut), but in Rc and Arc, writing shut the moment
sharing began, because both gave only shared access to the value they held. In this lesson
that door reopens, but with a different key: not through the type itself, but through a
permission tested at runtime.
The Rule Changes Location
The Borrow Checker lesson showed that the rule “either one mutable reference or several
shared references at a time” is tested at compile time: a piece of writing that violates
this rule never compiled at all. RefCell<T> is a container that carries the same rule to
runtime. Shared access to the value inside it is requested with borrow(), mutable
access with borrow_mut(); RefCell tracks these requests with a counter, and a request
that violates the rule is not left uncompiled, it is rejected at runtime.
In this lesson the rejection is measured not with a panic, but with a result returned by
try_borrow() and try_borrow_mut() calls: Ok if the request is valid, Err if not. No
panic text is printed anywhere; only whether the request succeeded is printed, as a bool.
RefCell, just like Rc and Arc, keeps a counter too — but what it counts is different.
Rc and Arc’s counter counted how many owners there were, and the value ended once it
hit zero. RefCell’s counter counts how many active borrows there are, and it never
ends the value; it only tracks which kind of access (shared or mutable) is currently
ongoing. These two counters can work independently of each other: an Rc<RefCell<T>> value
can hold both how many copies it has and who currently borrows it, at the same time, with
two separate counters. While one copy ends and the ownership counter falls, the borrow
counter is entirely unaware of whether that copy currently held a borrow — the two answer
separate questions.
A Binding’s Immutability Does Not Bind Its Content
The run below shows this directly: the binding named counter does not take mut, but
the integer inside it changes anyway.
SO10. — the claim that “the value inside can change while the binding itself stays
immutable” is confirmed by the run below: even though the line
let counter: RefCell<i64> = RefCell::new(0); does not contain mut, the line
value after the first write: 1 is printed.
// borrow_time_shift.rs — a rule rejected at compile time turns into a result at runtime
use std::cell::RefCell;
fn main() {
let counter: RefCell<i64> = RefCell::new(0); // binding is not mut
{
let mut writer = counter.borrow_mut();
*writer += 1;
}
println!("value after the first write: {}", counter.borrow());
let first_read = counter.borrow();
let second_read = counter.borrow();
println!("two reads together: {} and {}", *first_read, *second_read);
let write_attempt = counter.try_borrow_mut();
println!("did the write attempt succeed while reading was ongoing: {}", write_attempt.is_ok());
drop(first_read);
drop(second_read);
let second_write_attempt = counter.try_borrow_mut();
println!(
"did the write attempt succeed after the reads were dropped: {}",
second_write_attempt.is_ok()
);
}
value after the first write: 1 two reads together: 1 and 1 did the write attempt succeed while reading was ongoing: false did the write attempt succeed after the reads were dropped: true
The counter binding does not carry mut anywhere, but the borrow_mut() call genuinely
changes the value inside it. The Variables and Immutability lesson established that not
taking mut guarantees no change is allowed through the name itself; RefCell does not
break this guarantee — the name counter itself still cannot be rebound, none of its fields
can be changed with an ordinary assignment. borrow_mut() enters not through the name
itself, but through a separate gate RefCell checks at runtime. While first_read and
second_read live at the same time, both can read the same value — “many readers” is
permitted. A write attempt arriving while they are still alive fails; the same attempt
succeeds once both are dropped. The rule is the same: one writer or many readers, never
both.
Two Writers Violate the Same Rule Too
The rule covers not only “no reading while writing” but also the part that says “no two writers at the same time”. The run below tests this separately: the same rejection happens when a second mutable borrow is requested while a mutable borrow is already ongoing.
SO11. — the claim that “two mutable borrows at the same time are both rejected” is
confirmed by the did the second write attempt succeed while the first writer was active: false line in the run below.
// two_writers_attempt.rs — two mutable borrows at the same time are both rejected
use std::cell::RefCell;
fn main() {
let data: RefCell<Vec<i32>> = RefCell::new(vec![1, 2, 3]);
let mut first_writer = data.borrow_mut();
first_writer.push(4);
let second_write_attempt = data.try_borrow_mut();
println!(
"did the second write attempt succeed while the first writer was active: {}",
second_write_attempt.is_ok()
);
drop(first_writer);
let is_it_successful_now = data.try_borrow_mut();
println!("successful after the first writer is dropped: {}", is_it_successful_now.is_ok());
drop(is_it_successful_now);
println!("data length: {}", data.borrow().len());
}
did the second write attempt succeed while the first writer was active: false successful after the first writer is dropped: true data length: 4
The second attempt, opened while first_writer is still held, is rejected too — this time
not two colliding reads, but two write requests. An element having been pushed to data
through first_writer (push(4)) becomes permanent the instant the guard is dropped: the
length in the last line is four. The counter distinguishes not just how many readers are
active, but also the kind of active access — while a writer exists, neither another
writer nor a reader is accepted.
These two runs (the previous section’s and this section’s) together cover the whole rule:
many readers were permitted, while a reader with a writer, and two writers, were both
forbidden. Two of the three cases were measured by try_borrow or try_borrow_mut
returning Err, the remaining one by returning Ok; no panic text was printed in any of
them, no program crashed in any of them. The “modifying while reading” violation rejected
at compile time in the Borrow Checker lesson meets us here under the same name, but as an
Err at runtime.
The Rule Does Not Disappear, It Changes Location
None of the three rejection examples in the Borrow Checker lesson entered a code block —
all of them were described in prose, with the accepted solution placed next to them; the
violating source never compiled. Here the same kind of violation — another access to a
value being requested while it is being written — genuinely compiles; it only produces
an Err at runtime. This does not mean the rule has disappeared: an error avoided at
compile time turns into a result at runtime. The write attempt line printing false in the
run above shows that a violation of the kind that would never compile in the Borrow Checker
lesson compiles here and is caught at runtime — the program does not crash, because the
rejection is turned into a Result by try_borrow_mut, instead of a panic.
This trade-off’s cost is this: compile-time checking filters out a piece of writing before it runs; runtime checking only catches the same violation once that line actually runs. Wrong code no longer fails to compile — instead it reports its own failure while running — but this report only happens once that line is reached; a run that never hits the violation never shows the problem at all. The principle measured in the Borrow Checker lesson — “the checker secures ownership discipline, not correctness” — goes one step further here: runtime checking, too, only tests the path that runs, it cannot see a violation on a line the code never reaches.
The Ledger Itself Is Explained in This Lesson
This was exactly the question the Variables and Immutability lesson left to the Ownership
and the Memory Model course: when the same data is shared through another binding or
reference, does immutability still hold from there? The ledger used throughout every lesson
of this course carried the answer to this question implicitly from the start. ledger was a
value of type Rc<RefCell<Vec<String>>>. The Reference Counting lesson explained the Rc
half, this lesson explains the RefCell half: ledger was shared (Rc) and its content was
modifiable (RefCell) — using two nested containers provided both sharing and
modifiability at the same time.
SO12. — the claim that “a function taking &Ledger can still modify its content” is
confirmed by the run below: even though the add_record function’s parameter is &Ledger
(not a mutable reference), the line record count: 2 is printed.
// the_ledgers_interior_mutability.rs — the ledger type used throughout this topic worked this way
use std::cell::RefCell;
use std::rc::Rc;
type Ledger = Rc<RefCell<Vec<String>>>;
struct Trace {
name: String,
ledger: Ledger,
}
impl Trace {
fn new(name: &str, ledger: &Ledger) -> Trace {
Trace {
name: name.to_string(),
ledger: Rc::clone(ledger),
}
}
}
impl Drop for Trace {
fn drop(&mut self) {
self.ledger.borrow_mut().push(self.name.clone());
}
}
fn add_record(ledger: &Ledger, text: &str) {
// The ledger parameter is taken with &, not mut; the content still changes.
ledger.borrow_mut().push(text.to_string());
}
fn main() {
let ledger: Ledger = Rc::new(RefCell::new(Vec::new()));
add_record(&ledger, "added-by-hand");
{
let _measurement = Trace::new("added-on-drop", &ledger);
}
let record = ledger.borrow();
println!("records: {}", record.join(", "));
println!("record count: {}", record.len());
}
records: added-by-hand, added-on-drop record count: 2
The add_record function takes ledger: &Ledger — that is, &Rc<RefCell<Vec<String>>>, an
ordinary shared reference. Despite this, the ledger.borrow_mut().push(...) call genuinely
adds an element to the list. This is the very thing every Trace::drop call has been doing
since the topic’s first lesson: even though the drop function takes &mut self,
self.ledger could not be modified directly because it was an Rc — the line
self.ledger.borrow_mut().push(...) used not the access &mut self itself grants, but the
runtime access RefCell grants. Every run since the start of the topic was actually using,
in the background, the very mechanism this lesson measures — only this lesson names that
mechanism explicitly.
This also explains why ledger is defined as Rc<RefCell<Vec<String>>> rather than plain
Vec<String>. Vec<String> alone would be neither shareable nor modifiable from several
places — a function would need to either own it or be given an ordinary reference to it,
and calling push through an ordinary reference would require &mut. Rc brought
sharing, RefCell brought modifiability; together, even though only &Ledger was passed
every time Trace::new was called, every Trace could write its own end to the same list.
This topic’s first three lessons only took up the Rc half of this nested container; this
lesson completes the other half.
Access Is Not Transparent, It Requires an Explicit Call
The Heap-Allocated Single Ownership lesson showed that fields inside a Box can be
accessed directly: the writing point.x was the same whether the value was on the heap or
not. In the Atomic Reference Counting lesson, shared.value could also be read directly.
RefCell does not carry this transparency forward: the value inside it cannot be reached by
writing cell.field directly, borrow() or borrow_mut() must first be called to obtain a
guard value. This difference is not a coincidence — Box, Rc, and Arc knew at compile
time that access to the value inside was always safe, so they could hide that access.
RefCell, on the other hand, tests safety only at runtime, on every single call; this test
needs to be visible in the source, because the test can fail too.
This visibility takes concrete form in the Result value try_borrow_mut returns: while
writing to a Box field is an operation that never fails, writing to a RefCell is an
operation that is attempted and whose result must be checked. The code cannot hide this
difference — the calling side is forced to handle, on the very next line, whether its
request was accepted. This is the final step of a pattern the topic has followed across
three measurements: when an operation is certain and when it is conditional can be read
from the type that operation returns — () always succeeds, Result may succeed or may
fail.
With these four measurements together, the full picture of the topic emerges. Heap-Allocated Single Ownership established that a value can sit on the heap while its owner stays singular. Reference Counting opened that singularity into a countable plurality. Atomic Reference Counting showed that this counting gives the same result in different contexts too, but that shared data still stays unmodifiable. This lesson removed that last constraint, at a cost: modification is now possible, but every attempt now returns a result, and that result has to be checked. At no step across the topic was determinism lost — every end, every counter change, every borrow attempt was produced in the same order, with the same result, in the run.
Summary
RefCell<T>is a container that tests the “one writer or many readers” rule with a counter at runtime instead of compile time; the value inside it ends its life with its container.- Even when a binding itself is not
mut, the value inside it can change throughRefCell; the name itself still cannot be rebound, the change passes through a separate gate. - The same data can also be modified with
RefCellwhen shared across several bindings (Rccopies); this answers the question the Ownership and the Memory Model course left open about “what happens to immutability under sharing” — theledgertype used since the start of the course was exactly this mechanism. - A failed borrow attempt is measured not with a panic, but with a result returned by
try_borrow/try_borrow_mut. - The rule does not disappear, it changes location: a violation avoided at compile time
turns into a result (
Err) at runtime. - Access through
RefCellis not transparent; every access requires an explicitborrowcall, because a call may not always succeed.
Next Step
Across these four lessons a value’s ownership multiplied, became countable, went atomic, and
finally became modifiable while shared — but one single assumption was never questioned the
whole time: the values being shared were independent of one another, none of them was
holding onto another. Every counter counted the copies remaining for its own value; no
copy held the counted value itself backward. The course’s final question asks exactly this:
what if two values, through RefCell, each place an Rc inside the other and end up
holding onto each other? Can the counter still drop to zero then, or can it never drop at
all?
To keep your progress and take notes, Log in
My notes
Log in to take notes.