Skip to content
academia.sh

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 through RefCell; the name itself still cannot be rebound, the change passes through a separate gate.
  • The same data can also be modified with RefCell when shared across several bindings (Rc copies); this answers the question the Ownership and the Memory Model course left open about “what happens to immutability under sharing” — the ledger type 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 RefCell is not transparent; every access requires an explicit borrow call, 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close