Skip to content

← Smart Pointers and Interior Mutability step 25 of 26

Hard Research

Build `Cell<T>` and `RefCell<T>` from scratch

Re-implement the two types this whole track has been built on, over UnsafeCell, and drive each one from a script.

pub fn cell_script(ops: Vec<String>) -> Vec<i64>
pub fn refcell_semantics(script: Vec<String>) -> Vec<String>

Both interpreters are written for you. What is missing is MyCell<T> and MyRefCell<T> — the parts that need unsafe.

MyCell<T> over two cells named A and B

op pushes
set <c> <n> 0
get <c> the value
replace <c> <n> the old value
take <c> the old value, leaving the default
swap <c> <d> 0
bump <c> <n> the new value, via get_mut
consume <c> the old value, via into_inner, leaving a fresh cell
anything else, or an unknown cell name -1

MyRefCell<i64> with a stack of live guards

op logs
share share ok <value> / share fail
excl excl ok / excl fail
write <n> write <n> if the innermost guard is exclusive, else write fail
peek peek <value> from the innermost guard, else peek fail
release release <state> after dropping the innermost guard, else release none
state state <n>
panic_share share ok, or panic: <message>
panic_excl excl ok, or panic: <message>

The state counter is the whole design: 0 free, n > 0 means n shared borrows, −1 means one exclusive borrow. borrow() panics with MyRefCell already mutably borrowed; borrow_mut() panics with MyRefCell already borrowed — the same direction convention real RefCell uses on 1.95.

The smallest complete sound abstraction in std

UnsafeCell<T> is the only type the compiler treats specially. It is the sole legal way to get a *mut T — and from it a &mut T — out of a &T, because it is the sole type for which the compiler suppresses the noalias promise that &T otherwise carries. Everything with interior mutability in the language is a safe wrapper over it, and today you write two of them.

Teach yourself by contrast, because the two designs answer the same question in opposite ways.

MyCell needs no bookkeeping at all. Not because it is clever, but because of a decision about what it refuses to expose: it never hands out a reference to its interior. get copies out, set writes over, replace and swap exchange whole values. Since no &T into the cell can ever escape, there is nothing that a later write could invalidate. Sound by construction, zero runtime cost, no failure mode.

MyRefCell buys back the references and pays for them with a counter. Now a &T can escape — inside a guard — so something has to stop a writer appearing while it is alive. That something is one isize and a branch, and the proof that it works is that the guard’s Drop restores the count on every exit path, including a panic.

The rules your implementation has to respect

The guard must borrow the cell. MyRef<'a, T> holds &'a MyRefCell<T>, and that lifetime is what stops a guard outliving the cell it is counting for. Leave it off and the code often still compiles, because elision quietly supplies one — which is exactly why this needs to be stated rather than discovered.

The guard must not be Copy, and must not be constructible from outside. A Copy guard would decrement the count once per copy. A publicly constructible one would let anyone mint a borrow that was never checked. Both would make the unsafe blocks unsound, and neither is something the compiler can catch for you.

borrow_mut must not return &mut T directly. Clippy will stop you if you try:

error: mutable borrow from immutable input(s)
       (clippy::mut_from_ref)

mut_from_ref is in the correctness group and deny-by-default, and it is one of the rare cases where a lint is the safety review: a fn borrow_mut(&self) -> &mut T lets a caller obtain two &mut T at once from two &self calls, which is instant undefined behaviour. Returning a guard is what makes the design work at all.

The type must stay !Sync. It inherits that from UnsafeCell. If you ever hit a thread-related error here, adding unsafe impl Sync would “fix” it and would make the whole thing unsound — the counter is a plain Cell with no synchronisation. That is precisely why RefCell is Send but never Sync, and why Arc<RefCell<T>> does not compile.

swap A A

The real Cell::swap opens with:

if ptr::eq(self, other) { return; }

Your swap should too, and the swap A A case checks it. The invariant to protect is that a cell swapped with itself is unchanged, and that you never hand two aliasing pointers to a routine documented for two distinct locations. Since Rust 1.81 Cell::swap additionally panics on cells that partially overlap, which tells you how seriously the standard library takes the aliasing question here even where a naive implementation happens to produce the right answer on your machine. “Happens to work today” is not the bar for unsafe.

The honest limit: mem::forget

This design depends on the destructor running. std::mem::forget(guard) is a safe function that leaks a value without dropping it, and doing that to a MyRef leaves the count stuck above zero forever. No exclusive borrow will ever succeed again.

Is that a soundness hole? No — and understanding why is the sharpest lesson in this track. A stuck count only causes future borrows to fail. It never lets two conflicting borrows coexist, so it can never produce undefined behaviour. You get a permanently unusable cell, which is a bug, not a memory error.

Contrast that with the classic mem::forget unsoundness — leaking a guard that was holding a borrow of a partially-moved buffer, which is how the historical Vec::drain bug worked. There, forgetting the guard left the collection in a state where safe code could read uninitialised memory. The rule that fell out of that episode is the one this design satisfies: leaking must always be safe, so an abstraction may never rely on a destructor running for soundness — only for correctness.

The error waiting in the starter

error[E0133]: dereference of raw pointer is unsafe and requires
              unsafe function or block
  = note: raw pointers may be null, dangling or unaligned; they can violate
          aliasing rules and cause data races

UnsafeCell::get() is safe to call — it just hands you a *mut T. Dereferencing it is where you take responsibility, and the unsafe block is where you say so. Write a // SAFETY: comment on every one of them explaining which invariant makes it sound. Clippy’s undocumented_unsafe_blocks is allow-by-default so the gate will not ask you for it; every reviewer you will ever meet will.

Loading visualization…